xcodeproj.dart 18.2 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? deviceId = buildContext.deviceId;
189
    final List<String> showBuildSettingsCommand = <String>[
190 191
      ...xcrunCommand(),
      'xcodebuild',
192
      '-project',
193
      _fileSystem.path.absolute(projectPath),
194 195
      if (scheme != null)
        ...<String>['-scheme', scheme],
196 197 198 199
      if (configuration != null)
        ...<String>['-configuration', configuration],
      if (buildContext.environmentType == EnvironmentType.simulator)
        ...<String>['-sdk', 'iphonesimulator'],
200
      '-destination',
201 202 203 204 205
      if (buildContext.isWatch == true && buildContext.environmentType == EnvironmentType.physical)
        'generic/platform=watchOS'
      else if (buildContext.isWatch == true)
        'generic/platform=watchOS Simulator'
      else if (deviceId != null)
206 207 208 209 210
        'id=$deviceId'
      else if (buildContext.environmentType == EnvironmentType.physical)
        'generic/platform=iOS'
      else
        'generic/platform=iOS Simulator',
211
      '-showBuildSettings',
212
      'BUILD_DIR=${_fileSystem.path.absolute(getIosBuildDirectory())}',
213
      ...environmentVariablesAsXcodeBuildSettings(_platform),
214
    ];
215
    try {
216
      // showBuildSettings is reported to occasionally timeout. Here, we give it
217 218
      // a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
      // When there is a timeout, we retry once.
219
      final RunResult result = await _processUtils.run(
220
        showBuildSettingsCommand,
221
        throwOnError: true,
222 223 224 225 226 227
        workingDirectory: projectPath,
        timeout: timeout,
        timeoutRetries: 1,
      );
      final String out = result.stdout.trim();
      return parseXcodeBuildSettings(out);
228
    } on Exception catch (error) {
229 230
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
231
          type: 'ios',
232
          command: showBuildSettingsCommand.join(' '),
233
          flutterUsage: _usage,
234 235
        ).send();
      }
236
      _logger.printTrace('Unexpected failure to get Xcode build settings: $error.');
237 238 239 240 241 242
      return const <String, String>{};
    } finally {
      status.stop();
    }
  }

243 244 245 246 247 248 249 250 251 252 253 254
  /// 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();
255
    final String buildDirectory = _fileSystem.path.absolute(getIosBuildDirectory());
256 257 258 259 260 261 262 263 264
    final List<String> showBuildSettingsCommand = <String>[
      ...xcrunCommand(),
      'xcodebuild',
      '-alltargets',
      '-sdk',
      'iphonesimulator',
      '-project',
      podXcodeProject.path,
      '-showBuildSettings',
265 266
      'BUILD_DIR=$buildDirectory',
      'OBJROOT=$buildDirectory',
267 268 269 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
    ];
    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();
    }
  }

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

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

340 341 342 343
/// 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.
344
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
345
  const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
346
  return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
347 348 349 350 351 352 353 354
    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
355
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
356
  final Map<String, String> settings = <String, String>{};
357
  for (final Match? match in showBuildSettingsOutput.split('\n').map<Match?>(_settingExpr.firstMatch)) {
358
    if (match != null) {
359
      settings[match[1]!] = match[2]!;
360
    }
361 362 363 364 365 366
  }
  return settings;
}

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

373
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!);
374
}
375

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

  final String? scheme;
  final String? configuration;
  final EnvironmentType environmentType;
389
  final String? deviceId;
390
  final bool isWatch;
391 392

  @override
393
  int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId);
394 395 396 397 398 399 400 401 402

  @override
  bool operator ==(Object other) {
    if (identical(other, this)) {
      return true;
    }
    return other is XcodeProjectBuildContext &&
        other.scheme == scheme &&
        other.configuration == configuration &&
403
        other.deviceId == deviceId &&
404 405
        other.environmentType == environmentType &&
        other.isWatch == isWatch;
406 407 408
  }
}

409 410 411 412
/// Information about an Xcode project.
///
/// Represents the output of `xcodebuild -list`.
class XcodeProjectInfo {
413
  const XcodeProjectInfo(
414 415 416 417 418 419 420
    this.targets,
    this.buildConfigurations,
    this.schemes,
    Logger logger
  ) : _logger = logger;

  factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
421 422 423
    final List<String> targets = <String>[];
    final List<String> buildConfigurations = <String>[];
    final List<String> schemes = <String>[];
424
    List<String>? collector;
425
    for (final String line in output.split('\n')) {
426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
      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());
    }
441
    if (schemes.isEmpty) {
442
      schemes.add('Runner');
443
    }
444
    return XcodeProjectInfo(targets, buildConfigurations, schemes, logger);
445 446 447 448 449
  }

  final List<String> targets;
  final List<String> buildConfigurations;
  final List<String> schemes;
450
  final Logger _logger;
451 452 453 454

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

  /// The expected scheme for [buildInfo].
455
  @visibleForTesting
456
  static String expectedSchemeFor(BuildInfo? buildInfo) {
457
    return sentenceCase(buildInfo?.flavor ?? 'runner');
458 459 460 461 462
  }

  /// The expected build configuration for [buildInfo] and [scheme].
  static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
463
    if (buildInfo.flavor == null) {
464
      return baseConfiguration;
465
    }
466
    return '$baseConfiguration-$scheme';
467 468
  }

469 470
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
471
  bool hasBuildConfigurationForBuildMode(String buildMode) {
472
    buildMode = buildMode.toLowerCase();
473
    for (final String name in buildConfigurations) {
474 475 476 477 478 479
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
480 481
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
482
  String? schemeFor(BuildInfo? buildInfo) {
483
    final String expectedScheme = expectedSchemeFor(buildInfo);
484
    if (schemes.contains(expectedScheme)) {
485
      return expectedScheme;
486
    }
487 488 489 490 491
    return _uniqueMatch(schemes, (String candidate) {
      return candidate.toLowerCase() == expectedScheme.toLowerCase();
    });
  }

492
  Never reportFlavorNotFoundAndExit() {
493 494 495 496 497 498 499 500 501
    _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.');
    }
  }

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

522
  static String _baseConfigurationFor(BuildInfo buildInfo) {
523
    if (buildInfo.isDebug) {
524
      return 'Debug';
525 526
    }
    if (buildInfo.isProfile) {
527
      return 'Profile';
528
    }
529 530
    return 'Release';
  }
531

532
  static String? _uniqueMatch(Iterable<String> strings, bool Function(String s) matches) {
533
    final List<String> options = strings.where(matches).toList();
534
    if (options.length == 1) {
535
      return options.first;
536 537
    }
    return null;
538 539 540 541 542 543 544
  }

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