build_ios.dart 10.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
import 'package:file/file.dart';
6 7
import 'package:meta/meta.dart';

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

xster's avatar
xster committed
21
/// Builds an .app for an iOS app to be used for local testing on an iOS device
22
/// or simulator. Can only be run on a macOS host.
23 24
class BuildIOSCommand extends _BuildIOSSubCommand {
  BuildIOSCommand({ @required bool verboseHelp }) : super(verboseHelp: verboseHelp) {
25
    argParser
26 27 28 29 30
      ..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.'
      )
31
      ..addFlag('simulator',
32 33
        help: 'Build for the iOS simulator instead of the device. This changes '
          'the default build mode to debug if otherwise unspecified.',
34 35 36 37
      )
      ..addFlag('codesign',
        defaultsTo: true,
        help: 'Codesign the application bundle (only available on device builds).',
38
      );
39 40 41 42 43 44
  }

  @override
  final String name = 'ios';

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

47 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 62
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
/// App Store submission.
///
63 64
/// Can only be run on a macOS host.
class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
65 66 67 68 69 70 71 72 73 74
  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.',
    );
  }
75 76

  @override
77 78 79 80
  final String name = 'ipa';

  @override
  final List<String> aliases = <String>['xcarchive'];
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95

  @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;
96 97 98 99 100 101 102 103 104

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

  @override
  Future<FlutterCommandResult> runCommand() async {
    if (exportOptionsPlist != null) {
      final FileSystemEntityType type = globals.fs.typeSync(exportOptionsPlist);
      if (type == FileSystemEntityType.notFound) {
        throwToolExit(
105
            '"$exportOptionsPlist" property list does not exist.');
106 107 108 109 110 111
      } else if (type != FileSystemEntityType.file) {
        throwToolExit(
            '"$exportOptionsPlist" is not a file. See "xcodebuild -h" for available keys.');
      }
    }
    final FlutterCommandResult xcarchiveResult = await super.runCommand();
112
    final BuildInfo buildInfo = await getBuildInfo();
113 114 115 116 117 118 119 120 121 122 123 124

    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.
125
    final BuildableIOSApp app = await buildableIOSApp(buildInfo);
126 127 128 129 130 131 132 133 134 135 136 137 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
    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();
  }
167 168 169 170 171 172 173 174 175 176 177 178 179 180
}

abstract class _BuildIOSSubCommand extends BuildSubCommand {
  _BuildIOSSubCommand({ @required bool verboseHelp }) {
    addTreeShakeIconsFlag();
    addSplitDebugInfoOption();
    addBuildModeFlags(defaultToRelease: true);
    usesTargetOption();
    usesFlavorOption();
    usesPubOption();
    usesBuildNumberOption();
    usesBuildNameOption();
    addDartObfuscationOption();
    usesDartDefineOption();
181
    usesExtraDartFlagOptions();
182 183 184 185 186 187 188
    addEnableExperimentation(hide: !verboseHelp);
    addBuildPerformanceFile(hide: !verboseHelp);
    addBundleSkSLPathOption(hide: !verboseHelp);
    addNullSafetyModeOptions(hide: !verboseHelp);
    usesAnalyzeSizeFlag();
  }

189 190 191 192 193
  @override
  Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{
    DevelopmentArtifact.iOS,
  };

194 195 196 197 198
  XcodeBuildAction get xcodeBuildAction;
  bool get forSimulator;
  bool get configOnly;
  bool get shouldCodesign;

199
  Future<BuildableIOSApp> buildableIOSApp(BuildInfo buildInfo) async {
200 201 202 203 204 205 206 207 208
    _buildableIOSApp ??= await applicationPackages.getPackageForPlatform(
      TargetPlatform.ios,
      buildInfo: buildInfo,
    ) as BuildableIOSApp;
    return _buildableIOSApp;
  }

  BuildableIOSApp _buildableIOSApp;

209 210
  @override
  Future<FlutterCommandResult> runCommand() async {
211
    defaultBuildMode = forSimulator ? BuildMode.debug : BuildMode.release;
212
    final BuildInfo buildInfo = await getBuildInfo();
213

214
    if (!globals.platform.isMacOS) {
215 216 217 218 219 220 221 222 223 224 225 226 227
      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.',
      );
228
    }
229

230
    final BuildableIOSApp app = await buildableIOSApp(buildInfo);
231

232
    if (app == null) {
233
      throwToolExit('Application not configured for iOS');
234
    }
235

236
    final String logTarget = forSimulator ? 'simulator' : 'device';
237
    final String typeName = globals.artifacts.getEngineType(TargetPlatform.ios, buildInfo.mode);
238 239 240 241 242
    if (xcodeBuildAction == XcodeBuildAction.build) {
      globals.printStatus('Building $app for $logTarget ($typeName)...');
    } else {
      globals.printStatus('Archiving $app...');
    }
243
    final XcodeBuildResult result = await buildXcodeProject(
244
      app: app,
245
      buildInfo: buildInfo,
246
      targetOverride: targetFile,
247
      buildForDevice: !forSimulator,
248
      codesign: shouldCodesign,
249
      configOnly: configOnly,
250
      buildAction: xcodeBuildAction,
251
    );
252

253
    if (!result.success) {
254
      await diagnoseXcodeBuildFailure(result, globals.flutterUsage, globals.logger);
255
      throwToolExit('Encountered error while ${xcodeBuildAction.name}ing for $logTarget.');
256 257
    }

258 259 260 261
    if (buildInfo.codeSizeDirectory != null) {
      final SizeAnalyzer sizeAnalyzer = SizeAnalyzer(
        fileSystem: globals.fs,
        logger: globals.logger,
262
        flutterUsage: globals.flutterUsage,
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
        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');

      // This analysis is only supported for release builds, which also excludes the simulator.
      // Attempt to guess the correct .app by picking the first one.
      final Directory candidateDirectory = globals.fs.directory(
        globals.fs.path.join(getIosBuildDirectory(), 'Release-iphoneos'),
      );
      final Directory appDirectory = candidateDirectory.listSync()
        .whereType<Directory>()
        .firstWhere((Directory directory) {
        return globals.fs.path.extension(directory.path) == '.app';
      });
      final Map<String, Object> output = await sizeAnalyzer.analyzeAotSnapshot(
        aotSnapshot: aotSnapshot,
        precompilerTrace: precompilerTrace,
        outputDirectory: appDirectory,
        type: 'ios',
      );
      final File outputFile = globals.fsUtils.getUniqueFile(
289 290 291
        globals.fs
          .directory(globals.fsUtils.homeDirPath)
          .childDirectory('.flutter-devtools'), 'ios-code-size-analysis', 'json',
292 293 294 295 296 297 298
      )..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}',
      );
    }

299
    if (result.output != null) {
300
      globals.printStatus('Built ${result.output}.');
301 302

      return FlutterCommandResult.success();
303
    }
304

305
    return FlutterCommandResult.fail();
306 307
  }
}