mac.dart 32.4 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
import 'dart:async';

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

10
import '../artifacts.dart';
11
import '../base/file_system.dart';
12
import '../base/io.dart';
13
import '../base/logger.dart';
14
import '../base/process.dart';
15
import '../base/project_migrator.dart';
16
import '../base/utils.dart';
17
import '../build_info.dart';
18
import '../cache.dart';
19
import '../device.dart';
20
import '../flutter_manifest.dart';
21
import '../globals.dart' as globals;
22
import '../macos/cocoapod_utils.dart';
23
import '../macos/xcode.dart';
24 25
import '../migrations/xcode_project_object_version_migration.dart';
import '../migrations/xcode_script_build_phase_migration.dart';
26
import '../migrations/xcode_thin_binary_build_phase_input_paths_migration.dart';
27
import '../project.dart';
28
import '../reporting/reporting.dart';
29
import 'application_package.dart';
30
import 'code_signing.dart';
31
import 'migrations/host_app_info_plist_migration.dart';
32
import 'migrations/ios_deployment_target_migration.dart';
33
import 'migrations/project_base_configuration_migration.dart';
34
import 'migrations/project_build_location_migration.dart';
35
import 'migrations/remove_bitcode_migration.dart';
36
import 'migrations/remove_framework_link_and_embedding_migration.dart';
37
import 'migrations/xcode_build_system_migration.dart';
38
import 'xcode_build_settings.dart';
39
import 'xcodeproj.dart';
40
import 'xcresult.dart';
41

42 43 44
const String kConcurrentRunFailureMessage1 = 'database is locked';
const String kConcurrentRunFailureMessage2 = 'there are two concurrent builds running';

45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
/// User message when missing platform required to use Xcode.
///
/// Starting with Xcode 15, the simulator is no longer downloaded with Xcode
/// and must be downloaded and installed separately.
@visibleForTesting
String missingPlatformInstructions(String simulatorVersion) => '''
════════════════════════════════════════════════════════════════════════════════
$simulatorVersion is not installed. To download and install the platform, open
Xcode, select Xcode > Settings > Platforms, and click the GET button for the
required platform.

For more information, please visit:
  https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes
════════════════════════════════════════════════════════════════════════════════''';

60
class IMobileDevice {
61
  IMobileDevice({
62 63 64 65
    required Artifacts artifacts,
    required Cache cache,
    required ProcessManager processManager,
    required Logger logger,
66 67
  }) : _idevicesyslogPath = artifacts.getHostArtifact(HostArtifact.idevicesyslog).path,
      _idevicescreenshotPath = artifacts.getHostArtifact(HostArtifact.idevicescreenshot).path,
68 69 70
      _dyLdLibEntry = cache.dyLdLibEntry,
      _processUtils = ProcessUtils(logger: logger, processManager: processManager),
      _processManager = processManager;
71

72 73 74 75 76 77 78 79 80 81
  /// Create an [IMobileDevice] for testing.
  factory IMobileDevice.test({ required ProcessManager processManager }) {
    return IMobileDevice(
      artifacts: Artifacts.test(),
      cache: Cache.test(processManager: processManager),
      processManager: processManager,
      logger: BufferLogger.test(),
    );
  }

82 83
  final String _idevicesyslogPath;
  final String _idevicescreenshotPath;
84 85 86
  final MapEntry<String, String> _dyLdLibEntry;
  final ProcessManager _processManager;
  final ProcessUtils _processUtils;
87

88
  late final bool isInstalled = _processManager.canRun(_idevicescreenshotPath);
89

90
  /// Starts `idevicesyslog` and returns the running process.
91
  Future<Process> startLogger(String deviceID) {
92
    return _processUtils.start(
93 94 95 96 97 98
      <String>[
        _idevicesyslogPath,
        '-u',
        deviceID,
      ],
      environment: Map<String, String>.fromEntries(
99
        <MapEntry<String, String>>[_dyLdLibEntry]
100 101 102
      ),
    );
  }
103

104
  /// Captures a screenshot to the specified outputFile.
105 106 107
  Future<void> takeScreenshot(
    File outputFile,
    String deviceID,
108
    DeviceConnectionInterface interfaceType,
109
  ) {
110
    return _processUtils.run(
111 112
      <String>[
        _idevicescreenshotPath,
113
        outputFile.path,
114 115
        '--udid',
        deviceID,
116
        if (interfaceType == DeviceConnectionInterface.wireless)
117
          '--network',
118
      ],
119
      throwOnError: true,
120
      environment: Map<String, String>.fromEntries(
121
        <MapEntry<String, String>>[_dyLdLibEntry]
122 123
      ),
    );
124
  }
125 126
}

127
Future<XcodeBuildResult> buildXcodeProject({
128 129
  required BuildableIOSApp app,
  required BuildInfo buildInfo,
130
  String? targetOverride,
131
  EnvironmentType environmentType = EnvironmentType.physical,
132
  DarwinArch? activeArch,
133
  bool codesign = true,
134
  String? deviceID,
135
  bool isCoreDevice = false,
136
  bool configOnly = false,
137
  XcodeBuildAction buildAction = XcodeBuildAction.build,
138
}) async {
139
  if (!upgradePbxProjWithFlutterAssets(app.project, globals.logger)) {
140
    return XcodeBuildResult(success: false);
141
  }
142

143
  final List<ProjectMigrator> migrators = <ProjectMigrator>[
144
    RemoveFrameworkLinkAndEmbeddingMigration(app.project, globals.logger, globals.flutterUsage),
145
    XcodeBuildSystemMigration(app.project, globals.logger),
146
    ProjectBaseConfigurationMigration(app.project, globals.logger),
147
    ProjectBuildLocationMigration(app.project, globals.logger),
148
    IOSDeploymentTargetMigration(app.project, globals.logger),
149
    XcodeProjectObjectVersionMigration(app.project, globals.logger),
150
    HostAppInfoPlistMigration(app.project, globals.logger),
151
    XcodeScriptBuildPhaseMigration(app.project, globals.logger),
152
    RemoveBitcodeMigration(app.project, globals.logger),
153
    XcodeThinBinaryBuildPhaseInputPathsMigration(app.project, globals.logger),
154 155
  ];

156
  final ProjectMigration migration = ProjectMigration(migrators);
157
  migration.run();
158

159
  if (!_checkXcodeVersion()) {
160
    return XcodeBuildResult(success: false);
161
  }
162

163
  await removeFinderExtendedAttributes(app.project.parent.directory, globals.processUtils, globals.logger);
164

165 166 167 168 169 170
  final XcodeProjectInfo? projectInfo = await app.project.projectInfo();
  if (projectInfo == null) {
    globals.printError('Xcode project not found.');
    return XcodeBuildResult(success: false);
  }
  final String? scheme = projectInfo.schemeFor(buildInfo);
171
  if (scheme == null) {
172
    projectInfo.reportFlavorNotFoundAndExit();
173
  }
174
  final String? configuration = projectInfo.buildConfigurationFor(buildInfo, scheme);
175
  if (configuration == null) {
176 177 178 179 180 181 182
    globals.printError('');
    globals.printError('The Xcode project defines build configurations: ${projectInfo.buildConfigurations.join(', ')}');
    globals.printError('Flutter expects a build configuration named ${XcodeProjectInfo.expectedBuildConfigurationFor(buildInfo, scheme)} or similar.');
    globals.printError('Open Xcode to fix the problem:');
    globals.printError('  open ios/Runner.xcworkspace');
    globals.printError('1. Click on "Runner" in the project navigator.');
    globals.printError('2. Ensure the Runner PROJECT is selected, not the Runner TARGET.');
183
    if (buildInfo.isDebug) {
184
      globals.printError('3. Click the Editor->Add Configuration->Duplicate "Debug" Configuration.');
185
    } else {
186
      globals.printError('3. Click the Editor->Add Configuration->Duplicate "Release" Configuration.');
187
    }
188 189 190 191 192 193 194 195 196 197
    globals.printError('');
    globals.printError('   If this option is disabled, it is likely you have the target selected instead');
    globals.printError('   of the project; see:');
    globals.printError('   https://stackoverflow.com/questions/19842746/adding-a-build-configuration-in-xcode');
    globals.printError('');
    globals.printError('   If you have created a completely custom set of build configurations,');
    globals.printError('   you can set the FLUTTER_BUILD_MODE=${buildInfo.modeName.toLowerCase()}');
    globals.printError('   in the .xcconfig file for that configuration and run from Xcode.');
    globals.printError('');
    globals.printError('4. If you are not using completely custom build configurations, name the newly created configuration ${buildInfo.modeName}.');
198
    return XcodeBuildResult(success: false);
199 200
  }

201
  final FlutterManifest manifest = app.project.parent.manifest;
202
  final String? buildName = parsedBuildName(manifest: manifest, buildInfo: buildInfo);
203 204 205
  final bool buildNameIsMissing = buildName == null || buildName.isEmpty;

  if (buildNameIsMissing) {
206
    globals.printStatus('Warning: Missing build name (CFBundleShortVersionString).');
207 208
  }

209
  final String? buildNumber = parsedBuildNumber(manifest: manifest, buildInfo: buildInfo);
210 211 212
  final bool buildNumberIsMissing = buildNumber == null || buildNumber.isEmpty;

  if (buildNumberIsMissing) {
213
    globals.printStatus('Warning: Missing build number (CFBundleVersion).');
214 215
  }
  if (buildNameIsMissing || buildNumberIsMissing) {
216 217
    globals.printError('Action Required: You must set a build name and number in the pubspec.yaml '
      'file version field before submitting to the App Store.');
218 219
  }

220
  Map<String, String>? autoSigningConfigs;
221 222 223

  final Map<String, String> buildSettings = await app.project.buildSettingsForBuildInfo(
        buildInfo,
224 225
        environmentType: environmentType,
        deviceId: deviceID,
226 227
      ) ?? <String, String>{};

228
  if (codesign && environmentType == EnvironmentType.physical) {
229
    autoSigningConfigs = await getCodeSigningIdentityDevelopmentTeamBuildSetting(
230
      buildSettings: buildSettings,
231
      platform: globals.platform,
232
      processManager: globals.processManager,
233
      logger: globals.logger,
234 235
      config: globals.config,
      terminal: globals.terminal,
236
    );
237
  }
238

239
  final FlutterProject project = FlutterProject.current();
240 241
  await updateGeneratedXcodeProperties(
    project: project,
242
    targetOverride: targetOverride,
243
    buildInfo: buildInfo,
244
    usingCoreDevice: isCoreDevice,
245
  );
246
  await processPodsIfNeeded(project.ios, getIosBuildDirectory(), buildInfo.mode);
247 248 249
  if (configOnly) {
    return XcodeBuildResult(success: true);
  }
250

xster's avatar
xster committed
251
  final List<String> buildCommands = <String>[
252
    ...globals.xcode!.xcrunCommand(),
253
    'xcodebuild',
254 255
    '-configuration',
    configuration,
256 257 258
  ];

  if (globals.logger.isVerbose) {
xster's avatar
xster committed
259 260
    // An environment variable to be passed to xcode_backend.sh determining
    // whether to echo back executed commands.
261 262 263 264 265 266 267 268 269 270 271 272 273 274
    buildCommands.add('VERBOSE_SCRIPT_LOGGING=YES');
  } else {
    // This will print warnings and errors only.
    buildCommands.add('-quiet');
  }

  if (autoSigningConfigs != null) {
    for (final MapEntry<String, String> signingConfig in autoSigningConfigs.entries) {
      buildCommands.add('${signingConfig.key}=${signingConfig.value}');
    }
    buildCommands.add('-allowProvisioningUpdates');
    buildCommands.add('-allowProvisioningDeviceRegistration');
  }

275 276 277 278 279 280 281 282
  final Directory? workspacePath = app.project.xcodeWorkspace;
  if (workspacePath != null) {
    buildCommands.addAll(<String>[
      '-workspace', workspacePath.basename,
      '-scheme', scheme,
      if (buildAction != XcodeBuildAction.archive) // dSYM files aren't copied to the archive if BUILD_DIR is set.
        'BUILD_DIR=${globals.fs.path.absolute(getIosBuildDirectory())}',
    ]);
283
  }
284

285 286
  // Check if the project contains a watchOS companion app.
  final bool hasWatchCompanion = await app.project.containsWatchCompanion(
287 288 289
    projectInfo: projectInfo,
    buildInfo: buildInfo,
    deviceId: deviceID,
290 291 292 293
  );
  if (hasWatchCompanion) {
    // The -sdk argument has to be omitted if a watchOS companion app exists.
    // Otherwise the build will fail as WatchKit dependencies cannot be build using the iOS SDK.
294
    globals.printStatus('Watch companion app found.');
295
    if (environmentType == EnvironmentType.simulator && (deviceID == null || deviceID == '')) {
296 297 298 299 300 301 302
      globals.printError('No simulator device ID has been set.');
      globals.printError('A device ID is required to build an app with a watchOS companion app.');
      globals.printError('Please run "flutter devices" to get a list of available device IDs');
      globals.printError('and specify one using the -d, --device-id flag.');
      return XcodeBuildResult(success: false);
    }
  } else {
303
    if (environmentType == EnvironmentType.physical) {
304 305
      buildCommands.addAll(<String>['-sdk', 'iphoneos']);
    } else {
306
      buildCommands.addAll(<String>['-sdk', 'iphonesimulator']);
307 308
    }
  }
309

310 311 312 313 314 315 316 317 318
  buildCommands.add('-destination');
  if (deviceID != null) {
    buildCommands.add('id=$deviceID');
  } else if (environmentType == EnvironmentType.physical) {
    buildCommands.add('generic/platform=iOS');
  } else {
    buildCommands.add('generic/platform=iOS Simulator');
  }

319
  if (activeArch != null) {
320
    final String activeArchName = activeArch.name;
321 322 323 324 325
    buildCommands.add('ONLY_ACTIVE_ARCH=YES');
    // Setting ARCHS to $activeArchName will break the build if a watchOS companion app exists,
    // as it cannot be build for the architecture of the Flutter app.
    if (!hasWatchCompanion) {
      buildCommands.add('ARCHS=$activeArchName');
326 327
    }
  }
328

329 330 331 332 333 334 335 336 337
  if (!codesign) {
    buildCommands.addAll(
      <String>[
        'CODE_SIGNING_ALLOWED=NO',
        'CODE_SIGNING_REQUIRED=NO',
        'CODE_SIGNING_IDENTITY=""',
      ],
    );
  }
338

339 340 341
  Status? buildSubStatus;
  Status? initialBuildStatus;
  File? scriptOutputPipeFile;
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366
  RunResult? buildResult;
  XCResult? xcResult;

  final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_ios_build_temp_dir');
  try {
    if (globals.logger.hasTerminal) {
      scriptOutputPipeFile = tempDir.childFile('pipe_to_stdout');
      globals.os.makePipe(scriptOutputPipeFile.path);

      Future<void> listenToScriptOutputLine() async {
        final List<String> lines = await scriptOutputPipeFile!.readAsLines();
        for (final String line in lines) {
          if (line == 'done' || line == 'all done') {
            buildSubStatus?.stop();
            buildSubStatus = null;
            if (line == 'all done') {
              return;
            }
          } else {
            initialBuildStatus?.cancel();
            initialBuildStatus = null;
            buildSubStatus = globals.logger.startProgress(
              line,
              progressIndicatorPadding: kDefaultStatusPadding - 7,
            );
367
          }
368
        }
369
        await listenToScriptOutputLine();
370 371
      }

372 373
      // Trigger the start of the pipe -> stdout loop. Ignore exceptions.
      unawaited(listenToScriptOutputLine());
374

375 376
      buildCommands.add('SCRIPT_OUTPUT_STREAM_FILE=${scriptOutputPipeFile.absolute.path}');
    }
377

378
    final Directory resultBundleDirectory = tempDir.childDirectory(_kResultBundlePath);
379
    buildCommands.addAll(<String>[
380
      '-resultBundlePath',
381
      resultBundleDirectory.absolute.path,
382
      '-resultBundleVersion',
383
      _kResultBundleVersion,
384 385
    ]);

386 387 388 389 390 391 392 393 394 395 396 397 398
    // Don't log analytics for downstream Flutter commands.
    // e.g. `flutter build bundle`.
    buildCommands.add('FLUTTER_SUPPRESS_ANALYTICS=true');
    buildCommands.add('COMPILER_INDEX_STORE_ENABLE=NO');
    buildCommands.addAll(environmentVariablesAsXcodeBuildSettings(globals.platform));

    if (buildAction == XcodeBuildAction.archive) {
      buildCommands.addAll(<String>[
        '-archivePath',
        globals.fs.path.absolute(app.archiveBundlePath),
        'archive',
      ]);
    }
399

400 401
    final Stopwatch sw = Stopwatch()..start();
    initialBuildStatus = globals.logger.startProgress('Running Xcode build...');
402

403
    buildResult = await _runBuildWithRetries(buildCommands, app, resultBundleDirectory);
xster's avatar
xster committed
404

405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432
    // Notifies listener that no more output is coming.
    scriptOutputPipeFile?.writeAsStringSync('all done');
    buildSubStatus?.stop();
    buildSubStatus = null;
    initialBuildStatus?.cancel();
    initialBuildStatus = null;
    globals.printStatus(
      'Xcode ${xcodeBuildActionToString(buildAction)} done.'.padRight(kDefaultStatusPadding + 1)
          + getElapsedAsSeconds(sw.elapsed).padLeft(5),
    );
    globals.flutterUsage.sendTiming(xcodeBuildActionToString(buildAction), 'xcode-ios', Duration(milliseconds: sw.elapsedMilliseconds));

    if (tempDir.existsSync()) {
      // Display additional warning and error message from xcresult bundle.
      final Directory resultBundle = tempDir.childDirectory(_kResultBundlePath);
      if (!resultBundle.existsSync()) {
        globals.printTrace('The xcresult bundle are not generated. Displaying xcresult is disabled.');
      } else {
        // Discard unwanted errors. See: https://github.com/flutter/flutter/issues/95354
        final XCResultIssueDiscarder warningDiscarder = XCResultIssueDiscarder(typeMatcher: XCResultIssueType.warning);
        final XCResultIssueDiscarder dartBuildErrorDiscarder = XCResultIssueDiscarder(messageMatcher: RegExp(r'Command PhaseScriptExecution failed with a nonzero exit code'));
        final XCResultGenerator xcResultGenerator = XCResultGenerator(resultPath: resultBundle.absolute.path, xcode: globals.xcode!, processUtils: globals.processUtils);
        xcResult = await xcResultGenerator.generate(issueDiscarders: <XCResultIssueDiscarder>[warningDiscarder, dartBuildErrorDiscarder]);
      }
    }
  } finally {
    tempDir.deleteSync(recursive: true);
  }
433
  if (buildResult != null && buildResult.exitCode != 0) {
434
    globals.printStatus('Failed to build iOS app');
435
    return XcodeBuildResult(
436
      success: false,
xster's avatar
xster committed
437 438
      stdout: buildResult.stdout,
      stderr: buildResult.stderr,
439
      xcodeBuildExecution: XcodeBuildExecution(
xster's avatar
xster committed
440
        buildCommands: buildCommands,
441
        appDirectory: app.project.hostAppRoot.path,
442
        environmentType: environmentType,
xster's avatar
xster committed
443
        buildSettings: buildSettings,
444
      ),
445
      xcResult: xcResult,
446
    );
447
  } else {
448
    String? outputDir;
449 450 451 452 453
    if (buildAction == XcodeBuildAction.build) {
      // If the app contains a watch companion target, the sdk argument of xcodebuild has to be omitted.
      // For some reason this leads to TARGET_BUILD_DIR always ending in 'iphoneos' even though the
      // actual directory will end with 'iphonesimulator' for simulator builds.
      // The value of TARGET_BUILD_DIR is adjusted to accommodate for this effect.
454 455 456 457 458
      String? targetBuildDir = buildSettings['TARGET_BUILD_DIR'];
      if (targetBuildDir == null) {
        globals.printError('Xcode build is missing expected TARGET_BUILD_DIR build setting.');
        return XcodeBuildResult(success: false);
      }
459
      if (hasWatchCompanion && environmentType == EnvironmentType.simulator) {
460 461
        globals.printTrace('Replacing iphoneos with iphonesimulator in TARGET_BUILD_DIR.');
        targetBuildDir = targetBuildDir.replaceFirst('iphoneos', 'iphonesimulator');
462
      }
463
      final String? appBundle = buildSettings['WRAPPER_NAME'];
464 465
      final String expectedOutputDirectory = globals.fs.path.join(
        targetBuildDir,
466
        appBundle,
467
      );
468
      if (globals.fs.directory(expectedOutputDirectory).existsSync()) {
469 470
        // Copy app folder to a place where other tools can find it without knowing
        // the BuildInfo.
471 472 473 474 475 476 477 478 479
        outputDir = targetBuildDir.replaceFirst('/$configuration-', '/');
        globals.fs.directory(outputDir).createSync(recursive: true);

        // rsync instead of copy to maintain timestamps to support incremental
        // app install deltas. Use --delete to remove incompatible artifacts
        // (for example, kernel binary files produced from previous run).
        await globals.processUtils.run(
          <String>[
            'rsync',
480
            '-8', // Avoid mangling filenames with encodings that do not match the current locale.
481 482 483 484 485 486
            '-av',
            '--delete',
            expectedOutputDirectory,
            outputDir,
          ],
          throwOnError: true,
487
        );
488 489 490 491
        outputDir = globals.fs.path.join(
          outputDir,
          appBundle,
        );
492 493 494
      } else {
        globals.printError('Build succeeded but the expected app at $expectedOutputDirectory not found');
      }
495
    } else {
496
      outputDir = globals.fs.path.absolute(app.archiveBundleOutputPath);
497 498 499
      if (!globals.fs.isDirectorySync(outputDir)) {
        globals.printError('Archive succeeded but the expected xcarchive at $outputDir not found');
      }
500
    }
501 502 503 504 505 506
    return XcodeBuildResult(
        success: true,
        output: outputDir,
        xcodeBuildExecution: XcodeBuildExecution(
          buildCommands: buildCommands,
          appDirectory: app.project.hostAppRoot.path,
507
          environmentType: environmentType,
508 509
          buildSettings: buildSettings,
      ),
510
      xcResult: xcResult,
511
    );
512
  }
513 514
}

515 516
/// Extended attributes applied by Finder can cause code signing errors. Remove them.
/// https://developer.apple.com/library/archive/qa/qa1940/_index.html
517
Future<void> removeFinderExtendedAttributes(FileSystemEntity projectDirectory, ProcessUtils processUtils, Logger logger) async {
518 519 520 521 522 523
  final bool success = await processUtils.exitsHappy(
    <String>[
      'xattr',
      '-r',
      '-d',
      'com.apple.FinderInfo',
524
      projectDirectory.path,
525 526 527 528
    ]
  );
  // Ignore all errors, for example if directory is missing.
  if (!success) {
529
    logger.printTrace('Failed to remove xattr com.apple.FinderInfo from ${projectDirectory.path}');
530 531 532
  }
}

533
Future<RunResult?> _runBuildWithRetries(List<String> buildCommands, BuildableIOSApp app, Directory resultBundleDirectory) async {
534 535 536
  int buildRetryDelaySeconds = 1;
  int remainingTries = 8;

537
  RunResult? buildResult;
538
  while (remainingTries > 0) {
539 540
    if (resultBundleDirectory.existsSync()) {
      resultBundleDirectory.deleteSync(recursive: true);
541
    }
542 543 544
    remainingTries--;
    buildRetryDelaySeconds *= 2;

545
    buildResult = await globals.processUtils.run(
546 547 548 549 550 551 552 553 554 555 556 557
      buildCommands,
      workingDirectory: app.project.hostAppRoot.path,
      allowReentrantFlutter: true,
    );

    // If the result is anything other than a concurrent build failure, exit
    // the loop after the first build.
    if (!_isXcodeConcurrentBuildFailure(buildResult)) {
      break;
    }

    if (remainingTries > 0) {
558
      globals.printStatus('Xcode build failed due to concurrent builds, '
559 560 561
        'will retry in $buildRetryDelaySeconds seconds.');
      await Future<void>.delayed(Duration(seconds: buildRetryDelaySeconds));
    } else {
562
      globals.printStatus(
563 564 565 566 567 568 569 570 571 572 573
        'Xcode build failed too many times due to concurrent builds, '
        'giving up.');
      break;
    }
  }

  return buildResult;
}

bool _isXcodeConcurrentBuildFailure(RunResult result) {
return result.exitCode != 0 &&
574 575
    result.stdout.contains(kConcurrentRunFailureMessage1) &&
    result.stdout.contains(kConcurrentRunFailureMessage2);
576 577
}

578
Future<void> diagnoseXcodeBuildFailure(XcodeBuildResult result, Usage flutterUsage, Logger logger) async {
579
  final XcodeBuildExecution? xcodeBuildExecution = result.xcodeBuildExecution;
580 581 582
  if (xcodeBuildExecution != null
      && xcodeBuildExecution.environmentType == EnvironmentType.physical
      && (result.stdout?.toUpperCase().contains('BITCODE') ?? false)) {
583
    BuildEvent('xcode-bitcode-failure',
584
      type: 'ios',
585 586
      command: xcodeBuildExecution.buildCommands.toString(),
      settings: xcodeBuildExecution.buildSettings.toString(),
587
      flutterUsage: flutterUsage,
588
    ).send();
589 590
  }

591 592 593 594 595 596
  // Handle errors.
  final bool issueDetected = _handleIssues(result.xcResult, logger, xcodeBuildExecution);

  if (!issueDetected && xcodeBuildExecution != null) {
    // Fallback to use stdout to detect and print issues.
    _parseIssueInStdout(xcodeBuildExecution, logger, result);
597
  }
598 599
}

600 601 602 603 604
/// xcodebuild <buildaction> parameter (see man xcodebuild for details).
///
/// `clean`, `test`, `analyze`, and `install` are not supported.
enum XcodeBuildAction { build, archive }

605
String xcodeBuildActionToString(XcodeBuildAction action) {
606 607 608 609
    return switch (action) {
      XcodeBuildAction.build => 'build',
      XcodeBuildAction.archive => 'archive'
    };
610 611
}

612
class XcodeBuildResult {
613
  XcodeBuildResult({
614
    required this.success,
615 616 617 618
    this.output,
    this.stdout,
    this.stderr,
    this.xcodeBuildExecution,
619
    this.xcResult
620
  });
621

622
  final bool success;
623 624 625
  final String? output;
  final String? stdout;
  final String? stderr;
626
  /// The invocation of the build that resulted in this result instance.
627
  final XcodeBuildExecution? xcodeBuildExecution;
628 629 630 631
  /// Parsed information in xcresult bundle.
  ///
  /// Can be null if the bundle is not created during build.
  final XCResult? xcResult;
632 633 634 635
}

/// Describes an invocation of a Xcode build command.
class XcodeBuildExecution {
636
  XcodeBuildExecution({
637 638 639 640
    required this.buildCommands,
    required this.appDirectory,
    required this.environmentType,
    required this.buildSettings,
641
  });
642 643 644 645

  /// The original list of Xcode build commands used to produce this build result.
  final List<String> buildCommands;
  final String appDirectory;
646
  final EnvironmentType environmentType;
xster's avatar
xster committed
647 648
  /// The build settings corresponding to the [buildCommands] invocation.
  final Map<String, String> buildSettings;
649 650
}

651
final String _xcodeRequirement = 'Xcode $xcodeRequiredVersion or greater is required to develop for iOS.';
652 653

bool _checkXcodeVersion() {
654
  if (!globals.platform.isMacOS) {
655
    return false;
656
  }
657 658
  final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter;
  if (xcodeProjectInterpreter?.isInstalled != true) {
659
    globals.printError('Cannot find "xcodebuild". $_xcodeRequirement');
660 661
    return false;
  }
662 663
  if (globals.xcode?.isRequiredVersionSatisfactory != true) {
    globals.printError('Found "${xcodeProjectInterpreter?.versionText}". $_xcodeRequirement');
664 665
    return false;
  }
666 667 668
  return true;
}

669
// TODO(jmagman): Refactor to IOSMigrator.
670
bool upgradePbxProjWithFlutterAssets(IosProject project, Logger logger) {
671
  final File xcodeProjectFile = project.xcodeProjectInfoFile;
672 673
  assert(xcodeProjectFile.existsSync());
  final List<String> lines = xcodeProjectFile.readAsLinesSync();
674

675 676
  final RegExp oldAssets = RegExp(r'\/\* (flutter_assets|app\.flx)');
  final StringBuffer buffer = StringBuffer();
677
  final Set<String> printedStatuses = <String>{};
678

679
  for (final String line in lines) {
680
    final Match? match = oldAssets.firstMatch(line);
681
    if (match != null) {
682 683
      if (printedStatuses.add(match.group(1)!)) {
        logger.printStatus('Removing obsolete reference to ${match.group(1)} from ${project.xcodeProject.basename}');
684
      }
685 686 687
    } else {
      buffer.writeln(line);
    }
688
  }
689
  xcodeProjectFile.writeAsStringSync(buffer.toString());
690
  return true;
691
}
692

693
_XCResultIssueHandlingResult _handleXCResultIssue({required XCResultIssue issue, required Logger logger}) {
694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710
  // Issue summary from xcresult.
  final StringBuffer issueSummaryBuffer = StringBuffer();
  issueSummaryBuffer.write(issue.subType ?? 'Unknown');
  issueSummaryBuffer.write(' (Xcode): ');
  issueSummaryBuffer.writeln(issue.message ?? '');
  if (issue.location != null ) {
    issueSummaryBuffer.writeln(issue.location);
  }
  final String issueSummary = issueSummaryBuffer.toString();

  switch (issue.type) {
    case XCResultIssueType.error:
      logger.printError(issueSummary);
    case XCResultIssueType.warning:
      logger.printWarning(issueSummary);
  }

711 712 713 714 715 716 717 718 719 720
  final String? message = issue.message;
  if (message == null) {
    return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: false);
  }

  // Add more error messages for flutter users for some special errors.
  if (message.toLowerCase().contains('requires a provisioning profile.')) {
    return _XCResultIssueHandlingResult(requiresProvisioningProfile: true, hasProvisioningProfileIssue: true);
  } else if (message.toLowerCase().contains('provisioning profile')) {
    return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: true);
721 722 723 724 725
  } else if (message.toLowerCase().contains('ineligible destinations')) {
    final String? missingPlatform = _parseMissingPlatform(message);
    if (missingPlatform != null) {
      return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: false, missingPlatform: missingPlatform);
    }
726 727 728 729 730 731 732 733 734
  }
  return _XCResultIssueHandlingResult(requiresProvisioningProfile: false, hasProvisioningProfileIssue: false);
}

// Returns `true` if at least one issue is detected.
bool _handleIssues(XCResult? xcResult, Logger logger, XcodeBuildExecution? xcodeBuildExecution) {
  bool requiresProvisioningProfile = false;
  bool hasProvisioningProfileIssue = false;
  bool issueDetected = false;
735
  String? missingPlatform;
736 737 738 739 740 741 742 743 744 745

  if (xcResult != null && xcResult.parseSuccess) {
    for (final XCResultIssue issue in xcResult.issues) {
      final _XCResultIssueHandlingResult handlingResult = _handleXCResultIssue(issue: issue, logger: logger);
      if (handlingResult.hasProvisioningProfileIssue) {
        hasProvisioningProfileIssue = true;
      }
      if (handlingResult.requiresProvisioningProfile) {
        requiresProvisioningProfile = true;
      }
746
      missingPlatform = handlingResult.missingPlatform;
747 748 749 750 751 752 753 754
      issueDetected = true;
    }
  } else if (xcResult != null) {
    globals.printTrace('XCResult parsing error: ${xcResult.parsingErrorMessage}');
  }

  if (requiresProvisioningProfile) {
    logger.printError(noProvisioningProfileInstruction, emphasis: true);
755
  } else if ((!issueDetected || hasProvisioningProfileIssue) && _missingDevelopmentTeam(xcodeBuildExecution)) {
756 757 758
    issueDetected = true;
    logger.printError(noDevelopmentTeamInstruction, emphasis: true);
  } else if (hasProvisioningProfileIssue) {
759 760 761 762 763 764
    logger.printError('');
    logger.printError('It appears that there was a problem signing your application prior to installation on the device.');
    logger.printError('');
    logger.printError('Verify that the Bundle Identifier in your project is your signing id in Xcode');
    logger.printError('  open ios/Runner.xcworkspace');
    logger.printError('');
765
    logger.printError("Also try selecting 'Product > Build' to fix the problem.");
766 767
  } else if (missingPlatform != null) {
    logger.printError(missingPlatformInstructions(missingPlatform), emphasis: true);
768
  }
769

770 771 772 773 774 775 776 777 778 779 780 781
  return issueDetected;
}

// Return 'true' a missing development team issue is detected.
bool _missingDevelopmentTeam(XcodeBuildExecution? xcodeBuildExecution) {
  // Make sure the user has specified one of:
  // * DEVELOPMENT_TEAM (automatic signing)
  // * PROVISIONING_PROFILE (manual signing)
  return xcodeBuildExecution != null && xcodeBuildExecution.environmentType == EnvironmentType.physical &&
      !<String>['DEVELOPMENT_TEAM', 'PROVISIONING_PROFILE'].any(
        xcodeBuildExecution.buildSettings.containsKey);
}
782

783 784 785 786
// Detects and handles errors from stdout.
//
// As detecting issues in stdout is not usually accurate, this should be used as a fallback when other issue detecting methods failed.
void _parseIssueInStdout(XcodeBuildExecution xcodeBuildExecution, Logger logger, XcodeBuildResult result) {
787 788 789 790 791 792 793 794 795 796 797
  final String? stderr = result.stderr;
  if (stderr != null && stderr.isNotEmpty) {
    logger.printStatus('Error output from Xcode build:\n↳');
    logger.printStatus(stderr, indent: 4);
  }
  final String? stdout = result.stdout;
  if (stdout != null && stdout.isNotEmpty) {
    logger.printStatus("Xcode's output:\n↳");
    logger.printStatus(stdout, indent: 4);
  }

798 799 800 801 802
  if (xcodeBuildExecution.environmentType == EnvironmentType.physical
      // May need updating if Xcode changes its outputs.
      && (result.stdout?.contains('requires a provisioning profile. Select a provisioning profile in the Signing & Capabilities editor') ?? false)) {
    logger.printError(noProvisioningProfileInstruction, emphasis: true);
  }
803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819

  if (stderr != null && stderr.contains('Ineligible destinations')) {
    final String? version = _parseMissingPlatform(stderr);
      if (version != null) {
        logger.printError(missingPlatformInstructions(version), emphasis: true);
      }
  }
}

String? _parseMissingPlatform(String message) {
  final RegExp pattern = RegExp(r'error:(.*?) is not installed\. To use with Xcode, first download and install the platform');
  final RegExpMatch? match = pattern.firstMatch(message);
  if (match != null) {
    final String? version = match.group(1);
    return version;
  }
  return null;
820 821 822 823 824
}

// The result of [_handleXCResultIssue].
class _XCResultIssueHandlingResult {

825 826 827 828 829
  _XCResultIssueHandlingResult({
    required this.requiresProvisioningProfile,
    required this.hasProvisioningProfileIssue,
    this.missingPlatform,
  });
830 831 832 833 834 835

  // An issue indicates that user didn't provide the provisioning profile.
  final bool requiresProvisioningProfile;

  // An issue indicates that there is a provisioning profile issue.
  final bool hasProvisioningProfileIssue;
836 837

  final String? missingPlatform;
838 839 840 841
}

const String _kResultBundlePath = 'temporary_xcresult_bundle';
const String _kResultBundleVersion = '3';