xcodeproj.dart 18.3 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/memory.dart';
6
import 'package:meta/meta.dart';
7
import 'package:process/process.dart';
8

9
import '../base/common.dart';
10
import '../base/file_system.dart';
11
import '../base/io.dart';
12
import '../base/logger.dart';
13
import '../base/os.dart';
14
import '../base/platform.dart';
15
import '../base/process.dart';
16
import '../base/terminal.dart';
17
import '../base/utils.dart';
18
import '../base/version.dart';
19
import '../build_info.dart';
20
import '../reporting/reporting.dart';
21

22 23
final RegExp _settingExpr = RegExp(r'(\w+)\s*=\s*(.*)$');
final RegExp _varExpr = RegExp(r'\$\(([^)]*)\)');
24

25
/// Interpreter of Xcode projects.
26
class XcodeProjectInterpreter {
27
  factory XcodeProjectInterpreter({
28 29 30 31 32
    required Platform platform,
    required ProcessManager processManager,
    required Logger logger,
    required FileSystem fileSystem,
    required Usage usage,
33 34 35 36 37 38 39 40 41 42 43
  }) {
    return XcodeProjectInterpreter._(
      platform: platform,
      processManager: processManager,
      logger: logger,
      fileSystem: fileSystem,
      usage: usage,
    );
  }

  XcodeProjectInterpreter._({
44 45 46 47 48 49
    required Platform platform,
    required ProcessManager processManager,
    required Logger logger,
    required FileSystem fileSystem,
    required Usage usage,
    Version? version,
50
    String? build,
51
  }) : _platform = platform,
52 53 54 55 56 57 58 59 60
        _fileSystem = fileSystem,
        _logger = logger,
        _processUtils = ProcessUtils(logger: logger, processManager: processManager),
        _operatingSystemUtils = OperatingSystemUtils(
          fileSystem: fileSystem,
          logger: logger,
          platform: platform,
          processManager: processManager,
        ),
61
        _version = version,
62
        _build = build,
63
        _versionText = version?.toString(),
64 65 66 67 68 69 70
        _usage = usage;

  /// Create an [XcodeProjectInterpreter] for testing.
  ///
  /// Defaults to installed with sufficient version,
  /// a memory file system, fake platform, buffer logger,
  /// test [Usage], and test [Terminal].
71
  /// Set [version] to null to simulate Xcode not being installed.
72
  factory XcodeProjectInterpreter.test({
73 74
    required ProcessManager processManager,
    Version? version = const Version.withText(1000, 0, 0, '1000.0.0'),
75
    String? build = '13C100',
76 77 78 79 80 81 82 83 84
  }) {
    final Platform platform = FakePlatform(
      operatingSystem: 'macos',
      environment: <String, String>{},
    );
    return XcodeProjectInterpreter._(
      fileSystem: MemoryFileSystem.test(),
      platform: platform,
      processManager: processManager,
85
      usage: TestUsage(),
86
      logger: BufferLogger.test(),
87
      version: version,
88
      build: build,
89 90
    );
  }
91 92 93 94

  final Platform _platform;
  final FileSystem _fileSystem;
  final ProcessUtils _processUtils;
95
  final OperatingSystemUtils _operatingSystemUtils;
96
  final Logger _logger;
97
  final Usage _usage;
98
  static final RegExp _versionRegex = RegExp(r'Xcode ([0-9.]+).*Build version (\w+)');
99

100
  void _updateVersion() {
101
    if (!_platform.isMacOS || !_fileSystem.file('/usr/bin/xcodebuild').existsSync()) {
102 103 104
      return;
    }
    try {
105 106
      if (_versionText == null) {
        final RunResult result = _processUtils.runSync(
107
          <String>[...xcrunCommand(), 'xcodebuild', '-version'],
108 109 110 111 112
        );
        if (result.exitCode != 0) {
          return;
        }
        _versionText = result.stdout.trim().replaceAll('\n', ', ');
113
      }
114
      final Match? match = _versionRegex.firstMatch(versionText!);
115
      if (match == null) {
116
        return;
117
      }
118
      final String version = match.group(1)!;
119
      final List<String> components = version.split('.');
120 121 122 123
      final int majorVersion = int.parse(components[0]);
      final int minorVersion = components.length < 2 ? 0 : int.parse(components[1]);
      final int patchVersion = components.length < 3 ? 0 : int.parse(components[2]);
      _version = Version(majorVersion, minorVersion, patchVersion);
124
      _build = match.group(2);
125
    } on ProcessException {
126
      // Ignored, leave values null.
127 128
    }
  }
129

130
  bool get isInstalled => version != null;
131

132 133
  String? _versionText;
  String? get versionText {
134
    if (_versionText == null) {
135
      _updateVersion();
136
    }
137 138 139
    return _versionText;
  }

140
  Version? _version;
141
  String? _build;
142 143
  Version? get version {
    if (_version == null) {
144 145
      _updateVersion();
    }
146
    return _version;
147 148
  }

149 150 151 152 153 154 155
  String? get build {
    if (_build == null) {
      _updateVersion();
    }
    return _build;
  }

156 157 158 159 160 161 162 163
  /// The `xcrun` Xcode command to run or locate development
  /// tools and properties.
  ///
  /// Returns `xcrun` on x86 macOS.
  /// Returns `/usr/bin/arch -arm64e xcrun` on ARM macOS to force Xcode commands
  /// to run outside the x86 Rosetta translation, which may cause crashes.
  List<String> xcrunCommand() {
    final List<String> xcrunCommand = <String>[];
164
    if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm64) {
165 166 167 168 169 170 171 172 173 174
      // Force Xcode commands to run outside Rosetta.
      xcrunCommand.addAll(<String>[
        '/usr/bin/arch',
        '-arm64e',
      ]);
    }
    xcrunCommand.add('xcrun');
    return xcrunCommand;
  }

175 176
  /// Asynchronously retrieve xcode build settings. This one is preferred for
  /// new call-sites.
177 178 179
  ///
  /// If [scheme] is null, xcodebuild will return build settings for the first discovered
  /// target (by default this is Runner).
180
  Future<Map<String, String>> getBuildSettings(
181
    String projectPath, {
182
    required XcodeProjectBuildContext buildContext,
183 184
    Duration timeout = const Duration(minutes: 1),
  }) async {
185
    final Status status = _logger.startSpinner();
186 187
    final String? scheme = buildContext.scheme;
    final String? configuration = buildContext.configuration;
188
    final String? target = buildContext.target;
189
    final String? deviceId = buildContext.deviceId;
190
    final List<String> showBuildSettingsCommand = <String>[
191 192
      ...xcrunCommand(),
      'xcodebuild',
193
      '-project',
194
      _fileSystem.path.absolute(projectPath),
195 196
      if (scheme != null)
        ...<String>['-scheme', scheme],
197 198
      if (configuration != null)
        ...<String>['-configuration', configuration],
199 200
      if (target != null)
        ...<String>['-target', target],
201 202
      if (buildContext.environmentType == EnvironmentType.simulator)
        ...<String>['-sdk', 'iphonesimulator'],
203
      '-destination',
204
      if (buildContext.isWatch && buildContext.environmentType == EnvironmentType.physical)
205
        'generic/platform=watchOS'
206
      else if (buildContext.isWatch)
207 208
        'generic/platform=watchOS Simulator'
      else if (deviceId != null)
209 210 211 212 213
        'id=$deviceId'
      else if (buildContext.environmentType == EnvironmentType.physical)
        'generic/platform=iOS'
      else
        'generic/platform=iOS Simulator',
214
      '-showBuildSettings',
215
      'BUILD_DIR=${_fileSystem.path.absolute(getIosBuildDirectory())}',
216
      ...environmentVariablesAsXcodeBuildSettings(_platform),
217
    ];
218
    try {
219
      // showBuildSettings is reported to occasionally timeout. Here, we give it
220 221
      // a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
      // When there is a timeout, we retry once.
222
      final RunResult result = await _processUtils.run(
223
        showBuildSettingsCommand,
224
        throwOnError: true,
225 226 227 228 229 230
        workingDirectory: projectPath,
        timeout: timeout,
        timeoutRetries: 1,
      );
      final String out = result.stdout.trim();
      return parseXcodeBuildSettings(out);
231
    } on Exception catch (error) {
232 233
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
234
          type: 'ios',
235
          command: showBuildSettingsCommand.join(' '),
236
          flutterUsage: _usage,
237 238
        ).send();
      }
239
      _logger.printTrace('Unexpected failure to get Xcode build settings: $error.');
240 241 242 243 244 245
      return const <String, String>{};
    } finally {
      status.stop();
    }
  }

246 247 248 249 250 251 252 253 254 255 256 257
  /// Asynchronously retrieve xcode build settings for the generated Pods.xcodeproj plugins project.
  ///
  /// Returns the stdout of the Xcode command.
  Future<String?> pluginsBuildSettingsOutput(
      Directory podXcodeProject, {
        Duration timeout = const Duration(minutes: 1),
      }) async {
    if (!podXcodeProject.existsSync()) {
      // No plugins.
      return null;
    }
    final Status status = _logger.startSpinner();
258
    final String buildDirectory = _fileSystem.path.absolute(getIosBuildDirectory());
259 260 261 262 263 264 265 266 267
    final List<String> showBuildSettingsCommand = <String>[
      ...xcrunCommand(),
      'xcodebuild',
      '-alltargets',
      '-sdk',
      'iphonesimulator',
      '-project',
      podXcodeProject.path,
      '-showBuildSettings',
268 269
      'BUILD_DIR=$buildDirectory',
      'OBJROOT=$buildDirectory',
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300
    ];
    try {
      // showBuildSettings is reported to occasionally timeout. Here, we give it
      // a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
      // When there is a timeout, we retry once.
      final RunResult result = await _processUtils.run(
        showBuildSettingsCommand,
        throwOnError: true,
        workingDirectory: podXcodeProject.path,
        timeout: timeout,
        timeoutRetries: 1,
      );

      // Return the stdout only. Do not parse with parseXcodeBuildSettings, `-alltargets` prints the build settings
      // for all targets (one per plugin), so it would require a Map of Maps.
      return result.stdout.trim();
    } on Exception catch (error) {
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
          type: 'ios',
          command: showBuildSettingsCommand.join(' '),
          flutterUsage: _usage,
        ).send();
      }
      _logger.printTrace('Unexpected failure to get Pod Xcode project build settings: $error.');
      return null;
    } finally {
      status.stop();
    }
  }

301
  Future<void> cleanWorkspace(String workspacePath, String scheme, { bool verbose = false }) async {
302
    await _processUtils.run(<String>[
303 304
      ...xcrunCommand(),
      'xcodebuild',
305 306 307 308
      '-workspace',
      workspacePath,
      '-scheme',
      scheme,
309 310
      if (!verbose)
        '-quiet',
311
      'clean',
312
      ...environmentVariablesAsXcodeBuildSettings(_platform),
313
    ], workingDirectory: _fileSystem.currentDirectory.path);
314 315
  }

316
  Future<XcodeProjectInfo?> getInfo(String projectPath, {String? projectFilename}) async {
317 318 319 320
    // The exit code returned by 'xcodebuild -list' when either:
    // * -project is passed and the given project isn't there, or
    // * no -project is passed and there isn't a project.
    const int missingProjectExitCode = 66;
321 322
    // The exit code returned by 'xcodebuild -list' when the project is corrupted.
    const int corruptedProjectExitCode = 74;
323
    bool allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode;
324
    final RunResult result = await _processUtils.run(
325
      <String>[
326 327
        ...xcrunCommand(),
        'xcodebuild',
328 329 330 331
        '-list',
        if (projectFilename != null) ...<String>['-project', projectFilename],
      ],
      throwOnError: true,
332
      allowedFailures: allowedFailures,
333 334
      workingDirectory: projectPath,
    );
335
    if (allowedFailures(result.exitCode)) {
336
      // User configuration error, tool exit instead of crashing.
337 338
      throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
    }
339
    return XcodeProjectInfo.fromXcodeBuildOutput(result.toString(), _logger);
340
  }
xster's avatar
xster committed
341 342
}

343 344 345 346
/// Environment variables prefixed by FLUTTER_XCODE_ will be passed as build configurations to xcodebuild.
/// This allows developers to pass arbitrary build settings in without the tool needing to make a flag
/// for or be aware of each one. This could be used to set code signing build settings in a CI
/// environment without requiring settings changes in the Xcode project.
347
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
348
  const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
349
  return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
350 351 352 353 354 355 356 357
    return mapEntry.key.startsWith(xcodeBuildSettingPrefix);
  }).expand<String>((MapEntry<String, String> mapEntry) {
    // Remove FLUTTER_XCODE_ prefix from the environment variable to get the build setting.
    final String trimmedBuildSettingKey = mapEntry.key.substring(xcodeBuildSettingPrefix.length);
    return <String>['$trimmedBuildSettingKey=${mapEntry.value}'];
  }).toList();
}

xster's avatar
xster committed
358
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
359
  final Map<String, String> settings = <String, String>{};
360
  for (final Match? match in showBuildSettingsOutput.split('\n').map<Match?>(_settingExpr.firstMatch)) {
361
    if (match != null) {
362
      settings[match[1]!] = match[2]!;
363
    }
364 365 366 367 368 369
  }
  return settings;
}

/// Substitutes variables in [str] with their values from the specified Xcode
/// project and target.
370
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
371
  final Iterable<Match> matches = _varExpr.allMatches(str);
372
  if (matches.isEmpty) {
373
    return str;
374
  }
375

376
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!);
377
}
378

379 380
@immutable
class XcodeProjectBuildContext {
381 382 383 384 385
  const XcodeProjectBuildContext({
    this.scheme,
    this.configuration,
    this.environmentType = EnvironmentType.physical,
    this.deviceId,
386
    this.target,
387
    this.isWatch = false,
388
  });
389 390 391 392

  final String? scheme;
  final String? configuration;
  final EnvironmentType environmentType;
393
  final String? deviceId;
394
  final String? target;
395
  final bool isWatch;
396 397

  @override
398
  int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId, target);
399 400 401 402 403 404 405 406 407

  @override
  bool operator ==(Object other) {
    if (identical(other, this)) {
      return true;
    }
    return other is XcodeProjectBuildContext &&
        other.scheme == scheme &&
        other.configuration == configuration &&
408
        other.deviceId == deviceId &&
409
        other.environmentType == environmentType &&
410 411
        other.isWatch == isWatch &&
        other.target == target;
412 413 414
  }
}

415 416 417 418
/// Information about an Xcode project.
///
/// Represents the output of `xcodebuild -list`.
class XcodeProjectInfo {
419
  const XcodeProjectInfo(
420 421 422 423 424 425 426
    this.targets,
    this.buildConfigurations,
    this.schemes,
    Logger logger
  ) : _logger = logger;

  factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
427 428 429
    final List<String> targets = <String>[];
    final List<String> buildConfigurations = <String>[];
    final List<String> schemes = <String>[];
430
    List<String>? collector;
431
    for (final String line in output.split('\n')) {
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446
      if (line.isEmpty) {
        collector = null;
        continue;
      } else if (line.endsWith('Targets:')) {
        collector = targets;
        continue;
      } else if (line.endsWith('Build Configurations:')) {
        collector = buildConfigurations;
        continue;
      } else if (line.endsWith('Schemes:')) {
        collector = schemes;
        continue;
      }
      collector?.add(line.trim());
    }
447
    if (schemes.isEmpty) {
448
      schemes.add('Runner');
449
    }
450
    return XcodeProjectInfo(targets, buildConfigurations, schemes, logger);
451 452 453 454 455
  }

  final List<String> targets;
  final List<String> buildConfigurations;
  final List<String> schemes;
456
  final Logger _logger;
457 458 459 460

  bool get definesCustomSchemes => !(schemes.contains('Runner') && schemes.length == 1);

  /// The expected scheme for [buildInfo].
461
  @visibleForTesting
462
  static String expectedSchemeFor(BuildInfo? buildInfo) {
463
    return sentenceCase(buildInfo?.flavor ?? 'runner');
464 465 466 467 468
  }

  /// The expected build configuration for [buildInfo] and [scheme].
  static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
469
    if (buildInfo.flavor == null) {
470
      return baseConfiguration;
471
    }
472
    return '$baseConfiguration-$scheme';
473 474
  }

475 476
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
477
  bool hasBuildConfigurationForBuildMode(String buildMode) {
478
    buildMode = buildMode.toLowerCase();
479
    for (final String name in buildConfigurations) {
480 481 482 483 484 485
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
486

487 488
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
489
  String? schemeFor(BuildInfo? buildInfo) {
490
    final String expectedScheme = expectedSchemeFor(buildInfo);
491
    if (schemes.contains(expectedScheme)) {
492
      return expectedScheme;
493
    }
494 495 496 497 498
    return _uniqueMatch(schemes, (String candidate) {
      return candidate.toLowerCase() == expectedScheme.toLowerCase();
    });
  }

499
  Never reportFlavorNotFoundAndExit() {
500 501 502 503 504 505 506 507 508
    _logger.printError('');
    if (definesCustomSchemes) {
      _logger.printError('The Xcode project defines schemes: ${schemes.join(', ')}');
      throwToolExit('You must specify a --flavor option to select one of the available schemes.');
    } else {
      throwToolExit('The Xcode project does not define custom schemes. You cannot use the --flavor option.');
    }
  }

509 510
  /// Returns unique build configuration matching [buildInfo] and [scheme], or
  /// null, if there is no unique best match.
511 512 513 514
  String? buildConfigurationFor(BuildInfo? buildInfo, String scheme) {
    if (buildInfo == null) {
      return null;
    }
515
    final String expectedConfiguration = expectedBuildConfigurationFor(buildInfo, scheme);
516
    if (hasBuildConfigurationForBuildMode(expectedConfiguration)) {
517
      return expectedConfiguration;
518
    }
519 520 521
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
    return _uniqueMatch(buildConfigurations, (String candidate) {
      candidate = candidate.toLowerCase();
522
      if (buildInfo.flavor == null) {
523
        return candidate == expectedConfiguration.toLowerCase();
524 525
      }
      return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
526 527 528
    });
  }

529
  static String _baseConfigurationFor(BuildInfo buildInfo) {
530
    if (buildInfo.isDebug) {
531
      return 'Debug';
532 533
    }
    if (buildInfo.isProfile) {
534
      return 'Profile';
535
    }
536 537
    return 'Release';
  }
538

539
  static String? _uniqueMatch(Iterable<String> strings, bool Function(String s) matches) {
540
    final List<String> options = strings.where(matches).toList();
541
    if (options.length == 1) {
542
      return options.first;
543 544
    }
    return null;
545 546 547 548 549 550 551
  }

  @override
  String toString() {
    return 'XcodeProjectInfo($targets, $buildConfigurations, $schemes)';
  }
}