build_ios.dart 11.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
// @dart = 2.8

7
import 'package:file/file.dart';
8 9
import 'package:meta/meta.dart';

10
import '../base/analyze_size.dart';
11
import '../base/common.dart';
12 13
import '../base/logger.dart';
import '../base/process.dart';
14
import '../base/utils.dart';
15
import '../build_info.dart';
16
import '../convert.dart';
17
import '../globals.dart' as globals;
18
import '../ios/application_package.dart';
19
import '../ios/mac.dart';
20
import '../runner/flutter_command.dart';
21
import 'build.dart';
22

xster's avatar
xster committed
23
/// Builds an .app for an iOS app to be used for local testing on an iOS device
24
/// or simulator. Can only be run on a macOS host.
25 26
class BuildIOSCommand extends _BuildIOSSubCommand {
  BuildIOSCommand({ @required bool verboseHelp }) : super(verboseHelp: verboseHelp) {
27
    argParser
28 29 30 31 32
      ..addFlag('config-only',
        help: 'Update the project configuration without performing a build. '
          'This can be used in CI/CD process that create an archive to avoid '
          'performing duplicate work.'
      )
33
      ..addFlag('simulator',
34 35
        help: 'Build for the iOS simulator instead of the device. This changes '
          'the default build mode to debug if otherwise unspecified.',
36 37 38 39
      )
      ..addFlag('codesign',
        defaultsTo: true,
        help: 'Codesign the application bundle (only available on device builds).',
40
      );
41 42 43 44 45 46
  }

  @override
  final String name = 'ios';

  @override
47
  final String description = 'Build an iOS application bundle (Mac OS X host only).';
48

49 50 51 52 53 54 55 56 57 58 59
  @override
  final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.build;

  @override
  bool get forSimulator => boolArg('simulator');

  @override
  bool get configOnly => boolArg('config-only');

  @override
  bool get shouldCodesign => boolArg('codesign');
60 61

  @override
62
  Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput).parent;
63 64
}

65 66 67
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
/// App Store submission.
///
68 69
/// Can only be run on a macOS host.
class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
70 71 72 73 74 75 76 77 78 79
  BuildIOSArchiveCommand({@required bool verboseHelp})
      : super(verboseHelp: verboseHelp) {
    argParser.addOption(
      'export-options-plist',
      valueHelp: 'ExportOptions.plist',
      // TODO(jmagman): Update help text with link to Flutter docs.
      help:
          'Optionally export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.',
    );
  }
80 81

  @override
82 83 84 85
  final String name = 'ipa';

  @override
  final List<String> aliases = <String>['xcarchive'];
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100

  @override
  final String description = 'Build an iOS archive bundle (Mac OS X host only).';

  @override
  final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.archive;

  @override
  final bool forSimulator = false;

  @override
  final bool configOnly = false;

  @override
  final bool shouldCodesign = true;
101 102 103

  String get exportOptionsPlist => stringArg('export-options-plist');

104 105 106 107 108 109
  @override
  Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs
      .directory(xcodeResultOutput)
      .childDirectory('Products')
      .childDirectory('Applications');

110 111 112 113 114 115
  @override
  Future<FlutterCommandResult> runCommand() async {
    if (exportOptionsPlist != null) {
      final FileSystemEntityType type = globals.fs.typeSync(exportOptionsPlist);
      if (type == FileSystemEntityType.notFound) {
        throwToolExit(
116
            '"$exportOptionsPlist" property list does not exist.');
117 118 119 120 121 122
      } else if (type != FileSystemEntityType.file) {
        throwToolExit(
            '"$exportOptionsPlist" is not a file. See "xcodebuild -h" for available keys.');
      }
    }
    final FlutterCommandResult xcarchiveResult = await super.runCommand();
123
    final BuildInfo buildInfo = await getBuildInfo();
124
    displayNullSafetyMode(buildInfo);
125 126 127 128 129 130 131 132 133 134 135 136

    if (exportOptionsPlist == null) {
      return xcarchiveResult;
    }

    // xcarchive failed or not at expected location.
    if (xcarchiveResult.exitStatus != ExitStatus.success) {
      globals.logger.printStatus('Skipping IPA');
      return xcarchiveResult;
    }

    // Build IPA from generated xcarchive.
137
    final BuildableIOSApp app = await buildableIOSApp(buildInfo);
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
    Status status;
    RunResult result;
    final String outputPath = globals.fs.path.absolute(app.ipaOutputPath);
    try {
      status = globals.logger.startProgress('Building IPA...');

      result = await globals.processUtils.run(
        <String>[
          ...globals.xcode.xcrunCommand(),
          'xcodebuild',
          '-exportArchive',
          '-archivePath',
          globals.fs.path.absolute(app.archiveBundleOutputPath),
          '-exportPath',
          outputPath,
          '-exportOptionsPlist',
          globals.fs.path.absolute(exportOptionsPlist),
        ],
      );
    } finally {
      status.stop();
    }

    if (result.exitCode != 0) {
      final StringBuffer errorMessage = StringBuffer();

      // "error:" prefixed lines are the nicely formatted error message, the
      // rest is the same message but printed as a IDEFoundationErrorDomain.
      // Example:
      // error: exportArchive: exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd
      // Error Domain=IDEFoundationErrorDomain Code=1 "exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd" ...
      LineSplitter.split(result.stderr)
          .where((String line) => line.contains('error: '))
          .forEach(errorMessage.writeln);
      throwToolExit('Encountered error while building IPA:\n$errorMessage');
    }

    globals.logger.printStatus('Built IPA to $outputPath.');

    return FlutterCommandResult.success();
  }
179 180 181 182 183 184
}

abstract class _BuildIOSSubCommand extends BuildSubCommand {
  _BuildIOSSubCommand({ @required bool verboseHelp }) {
    addTreeShakeIconsFlag();
    addSplitDebugInfoOption();
185
    addBuildModeFlags(verboseHelp: verboseHelp, defaultToRelease: true);
186 187 188 189 190 191 192
    usesTargetOption();
    usesFlavorOption();
    usesPubOption();
    usesBuildNumberOption();
    usesBuildNameOption();
    addDartObfuscationOption();
    usesDartDefineOption();
193
    usesExtraDartFlagOptions(verboseHelp: verboseHelp);
194 195 196 197 198 199 200
    addEnableExperimentation(hide: !verboseHelp);
    addBuildPerformanceFile(hide: !verboseHelp);
    addBundleSkSLPathOption(hide: !verboseHelp);
    addNullSafetyModeOptions(hide: !verboseHelp);
    usesAnalyzeSizeFlag();
  }

201 202 203 204 205
  @override
  Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{
    DevelopmentArtifact.iOS,
  };

206 207 208 209 210
  XcodeBuildAction get xcodeBuildAction;
  bool get forSimulator;
  bool get configOnly;
  bool get shouldCodesign;

211
  Future<BuildableIOSApp> buildableIOSApp(BuildInfo buildInfo) async {
212 213 214 215 216 217 218 219 220
    _buildableIOSApp ??= await applicationPackages.getPackageForPlatform(
      TargetPlatform.ios,
      buildInfo: buildInfo,
    ) as BuildableIOSApp;
    return _buildableIOSApp;
  }

  BuildableIOSApp _buildableIOSApp;

221 222
  Directory _outputAppDirectory(String xcodeResultOutput);

223 224
  @override
  Future<FlutterCommandResult> runCommand() async {
225
    defaultBuildMode = forSimulator ? BuildMode.debug : BuildMode.release;
226
    final BuildInfo buildInfo = await getBuildInfo();
227

228
    if (!globals.platform.isMacOS) {
229 230 231 232 233 234 235 236 237 238 239 240 241
      throwToolExit('Building for iOS is only supported on macOS.');
    }
    if (forSimulator && !buildInfo.supportsSimulator) {
      throwToolExit('${toTitleCase(buildInfo.friendlyModeName)} mode is not supported for simulators.');
    }
    if (configOnly && buildInfo.codeSizeDirectory != null) {
      throwToolExit('Cannot analyze code size without performing a full build.');
    }
    if (!forSimulator && !shouldCodesign) {
      globals.printStatus(
        'Warning: Building for device with codesigning disabled. You will '
        'have to manually codesign before deploying to device.',
      );
242
    }
243

244
    final BuildableIOSApp app = await buildableIOSApp(buildInfo);
245

246
    if (app == null) {
247
      throwToolExit('Application not configured for iOS');
248
    }
249

250
    final String logTarget = forSimulator ? 'simulator' : 'device';
251
    final String typeName = globals.artifacts.getEngineType(TargetPlatform.ios, buildInfo.mode);
252 253 254 255 256
    if (xcodeBuildAction == XcodeBuildAction.build) {
      globals.printStatus('Building $app for $logTarget ($typeName)...');
    } else {
      globals.printStatus('Archiving $app...');
    }
257
    final XcodeBuildResult result = await buildXcodeProject(
258
      app: app,
259
      buildInfo: buildInfo,
260
      targetOverride: targetFile,
261
      buildForDevice: !forSimulator,
262
      codesign: shouldCodesign,
263
      configOnly: configOnly,
264
      buildAction: xcodeBuildAction,
265
    );
266

267
    if (!result.success) {
268
      await diagnoseXcodeBuildFailure(result, globals.flutterUsage, globals.logger);
269 270
      final String presentParticiple = xcodeBuildAction == XcodeBuildAction.build ? 'building' : 'archiving';
      throwToolExit('Encountered error while $presentParticiple for $logTarget.');
271 272
    }

273 274 275 276
    if (buildInfo.codeSizeDirectory != null) {
      final SizeAnalyzer sizeAnalyzer = SizeAnalyzer(
        fileSystem: globals.fs,
        logger: globals.logger,
277
        flutterUsage: globals.flutterUsage,
278 279 280 281 282 283 284 285 286
        appFilenamePattern: 'App'
      );
      // Only support 64bit iOS code size analysis.
      final String arch = getNameForDarwinArch(DarwinArch.arm64);
      final File aotSnapshot = globals.fs.directory(buildInfo.codeSizeDirectory)
        .childFile('snapshot.$arch.json');
      final File precompilerTrace = globals.fs.directory(buildInfo.codeSizeDirectory)
        .childFile('trace.$arch.json');

287 288 289 290 291 292 293 294 295 296 297 298 299
      final Directory outputAppDirectoryCandidate = _outputAppDirectory(result.output);

      Directory appDirectory;
      if (outputAppDirectoryCandidate.existsSync()) {
        appDirectory = outputAppDirectoryCandidate.listSync()
            .whereType<Directory>()
            .firstWhere((Directory directory) {
          return globals.fs.path.extension(directory.path) == '.app';
        }, orElse: () => null);
      }
      if (appDirectory == null) {
        throwToolExit('Could not find app to analyze code size in ${outputAppDirectoryCandidate.path}');
      }
300 301 302 303 304 305 306
      final Map<String, Object> output = await sizeAnalyzer.analyzeAotSnapshot(
        aotSnapshot: aotSnapshot,
        precompilerTrace: precompilerTrace,
        outputDirectory: appDirectory,
        type: 'ios',
      );
      final File outputFile = globals.fsUtils.getUniqueFile(
307 308 309
        globals.fs
          .directory(globals.fsUtils.homeDirPath)
          .childDirectory('.flutter-devtools'), 'ios-code-size-analysis', 'json',
310 311 312 313 314
      )..writeAsStringSync(jsonEncode(output));
      // This message is used as a sentinel in analyze_apk_size_test.dart
      globals.printStatus(
        'A summary of your iOS bundle analysis can be found at: ${outputFile.path}',
      );
315 316 317 318 319 320 321 322

      // DevTools expects a file path relative to the .flutter-devtools/ dir.
      final String relativeAppSizePath = outputFile.path.split('.flutter-devtools/').last.trim();
      globals.printStatus(
        '\nTo analyze your app size in Dart DevTools, run the following command:\n'
        'flutter pub global activate devtools; flutter pub global run devtools '
        '--appSizeBase=$relativeAppSizePath'
      );
323 324
    }

325
    if (result.output != null) {
326
      globals.printStatus('Built ${result.output}.');
327 328

      return FlutterCommandResult.success();
329
    }
330

331
    return FlutterCommandResult.fail();
332 333
  }
}