xcodeproj.dart 15.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
  }) : _platform = platform,
51 52 53 54 55 56 57 58 59
        _fileSystem = fileSystem,
        _logger = logger,
        _processUtils = ProcessUtils(logger: logger, processManager: processManager),
        _operatingSystemUtils = OperatingSystemUtils(
          fileSystem: fileSystem,
          logger: logger,
          platform: platform,
          processManager: processManager,
        ),
60
        _version = version,
61 62 63 64 65 66 67
        _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].
68
  /// Set [version] to null to simulate Xcode not being installed.
69
  factory XcodeProjectInterpreter.test({
70 71
    required ProcessManager processManager,
    Version? version = const Version.withText(1000, 0, 0, '1000.0.0'),
72 73 74 75 76 77 78 79 80
  }) {
    final Platform platform = FakePlatform(
      operatingSystem: 'macos',
      environment: <String, String>{},
    );
    return XcodeProjectInterpreter._(
      fileSystem: MemoryFileSystem.test(),
      platform: platform,
      processManager: processManager,
81
      usage: TestUsage(),
82
      logger: BufferLogger.test(),
83
      version: version,
84 85
    );
  }
86 87 88 89

  final Platform _platform;
  final FileSystem _fileSystem;
  final ProcessUtils _processUtils;
90
  final OperatingSystemUtils _operatingSystemUtils;
91
  final Logger _logger;
92
  final Usage _usage;
93

94
  static final RegExp _versionRegex = RegExp(r'Xcode ([0-9.]+)');
95

96
  void _updateVersion() {
97
    if (!_platform.isMacOS || !_fileSystem.file('/usr/bin/xcodebuild').existsSync()) {
98 99 100
      return;
    }
    try {
101 102
      if (_versionText == null) {
        final RunResult result = _processUtils.runSync(
103
          <String>[...xcrunCommand(), 'xcodebuild', '-version'],
104 105 106 107 108
        );
        if (result.exitCode != 0) {
          return;
        }
        _versionText = result.stdout.trim().replaceAll('\n', ', ');
109
      }
110
      final Match? match = _versionRegex.firstMatch(versionText!);
111
      if (match == null) {
112
        return;
113
      }
114
      final String version = match.group(1)!;
115
      final List<String> components = version.split('.');
116 117 118 119
      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);
120
    } on ProcessException {
121
      // Ignored, leave values null.
122 123
    }
  }
124

125
  bool get isInstalled => version != null;
126

127 128
  String? _versionText;
  String? get versionText {
129
    if (_versionText == null) {
130
      _updateVersion();
131
    }
132 133 134
    return _versionText;
  }

135 136 137
  Version? _version;
  Version? get version {
    if (_version == null) {
138 139
      _updateVersion();
    }
140
    return _version;
141 142
  }

143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
  /// 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>[];
    if (_operatingSystemUtils.hostPlatform == HostPlatform.darwin_arm) {
      // Force Xcode commands to run outside Rosetta.
      xcrunCommand.addAll(<String>[
        '/usr/bin/arch',
        '-arm64e',
      ]);
    }
    xcrunCommand.add('xcrun');
    return xcrunCommand;
  }

162 163
  /// Asynchronously retrieve xcode build settings. This one is preferred for
  /// new call-sites.
164 165 166
  ///
  /// If [scheme] is null, xcodebuild will return build settings for the first discovered
  /// target (by default this is Runner).
167
  Future<Map<String, String>> getBuildSettings(
168
    String projectPath, {
169
    required XcodeProjectBuildContext buildContext,
170 171
    Duration timeout = const Duration(minutes: 1),
  }) async {
172
    final Status status = _logger.startSpinner();
173 174
    final String? scheme = buildContext.scheme;
    final String? configuration = buildContext.configuration;
175
    final List<String> showBuildSettingsCommand = <String>[
176 177
      ...xcrunCommand(),
      'xcodebuild',
178
      '-project',
179
      _fileSystem.path.absolute(projectPath),
180 181
      if (scheme != null)
        ...<String>['-scheme', scheme],
182 183 184 185
      if (configuration != null)
        ...<String>['-configuration', configuration],
      if (buildContext.environmentType == EnvironmentType.simulator)
        ...<String>['-sdk', 'iphonesimulator'],
186
      '-showBuildSettings',
187
      'BUILD_DIR=${_fileSystem.path.absolute(getIosBuildDirectory())}',
188
      ...environmentVariablesAsXcodeBuildSettings(_platform)
189
    ];
190
    try {
191
      // showBuildSettings is reported to occasionally timeout. Here, we give it
192 193
      // a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
      // When there is a timeout, we retry once.
194
      final RunResult result = await _processUtils.run(
195
        showBuildSettingsCommand,
196
        throwOnError: true,
197 198 199 200 201 202
        workingDirectory: projectPath,
        timeout: timeout,
        timeoutRetries: 1,
      );
      final String out = result.stdout.trim();
      return parseXcodeBuildSettings(out);
203
    } on Exception catch (error) {
204 205
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
206
          type: 'ios',
207
          command: showBuildSettingsCommand.join(' '),
208
          flutterUsage: _usage,
209 210
        ).send();
      }
211
      _logger.printTrace('Unexpected failure to get Xcode build settings: $error.');
212 213 214 215 216 217
      return const <String, String>{};
    } finally {
      status.stop();
    }
  }

218
  Future<void> cleanWorkspace(String workspacePath, String scheme, { bool verbose = false }) async {
219
    await _processUtils.run(<String>[
220 221
      ...xcrunCommand(),
      'xcodebuild',
222 223 224 225
      '-workspace',
      workspacePath,
      '-scheme',
      scheme,
226 227
      if (!verbose)
        '-quiet',
228
      'clean',
229 230
      ...environmentVariablesAsXcodeBuildSettings(_platform)
    ], workingDirectory: _fileSystem.currentDirectory.path);
231 232
  }

233
  Future<XcodeProjectInfo> getInfo(String projectPath, {String? projectFilename}) async {
234 235 236 237
    // 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;
238 239 240
    // The exit code returned by 'xcodebuild -list' when the project is corrupted.
    const int corruptedProjectExitCode = 74;
    bool _allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode;
241
    final RunResult result = await _processUtils.run(
242
      <String>[
243 244
        ...xcrunCommand(),
        'xcodebuild',
245 246 247 248
        '-list',
        if (projectFilename != null) ...<String>['-project', projectFilename],
      ],
      throwOnError: true,
249
      allowedFailures: _allowedFailures,
250 251
      workingDirectory: projectPath,
    );
252 253
    if (_allowedFailures(result.exitCode)) {
      // User configuration error, tool exit instead of crashing.
254 255
      throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
    }
256
    return XcodeProjectInfo.fromXcodeBuildOutput(result.toString(), _logger);
257
  }
xster's avatar
xster committed
258 259
}

260 261 262 263
/// 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.
264
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
265
  const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
266
  return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
267 268 269 270 271 272 273 274
    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
275
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
276
  final Map<String, String> settings = <String, String>{};
277
  for (final Match? match in showBuildSettingsOutput.split('\n').map<Match?>(_settingExpr.firstMatch)) {
278
    if (match != null) {
279
      settings[match[1]!] = match[2]!;
280
    }
281 282 283 284 285 286
  }
  return settings;
}

/// Substitutes variables in [str] with their values from the specified Xcode
/// project and target.
287
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
288
  final Iterable<Match> matches = _varExpr.allMatches(str);
289
  if (matches.isEmpty) {
290
    return str;
291
  }
292

293
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!);
294
}
295

296 297 298 299 300 301 302 303 304
@immutable
class XcodeProjectBuildContext {
  const XcodeProjectBuildContext({this.scheme, this.configuration, this.environmentType = EnvironmentType.physical});

  final String? scheme;
  final String? configuration;
  final EnvironmentType environmentType;

  @override
305
  int get hashCode => Object.hash(scheme, configuration, environmentType);
306 307 308 309 310 311 312 313 314 315 316 317 318

  @override
  bool operator ==(Object other) {
    if (identical(other, this)) {
      return true;
    }
    return other is XcodeProjectBuildContext &&
        other.scheme == scheme &&
        other.configuration == configuration &&
        other.environmentType == environmentType;
  }
}

319 320 321 322
/// Information about an Xcode project.
///
/// Represents the output of `xcodebuild -list`.
class XcodeProjectInfo {
323 324 325 326 327 328 329 330
  XcodeProjectInfo(
    this.targets,
    this.buildConfigurations,
    this.schemes,
    Logger logger
  ) : _logger = logger;

  factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
331 332 333
    final List<String> targets = <String>[];
    final List<String> buildConfigurations = <String>[];
    final List<String> schemes = <String>[];
334
    List<String>? collector;
335
    for (final String line in output.split('\n')) {
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
      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());
    }
351
    if (schemes.isEmpty) {
352
      schemes.add('Runner');
353
    }
354
    return XcodeProjectInfo(targets, buildConfigurations, schemes, logger);
355 356 357 358 359
  }

  final List<String> targets;
  final List<String> buildConfigurations;
  final List<String> schemes;
360
  final Logger _logger;
361 362 363 364

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

  /// The expected scheme for [buildInfo].
365
  @visibleForTesting
366
  static String expectedSchemeFor(BuildInfo? buildInfo) {
367
    return toTitleCase(buildInfo?.flavor ?? 'runner');
368 369 370 371 372
  }

  /// The expected build configuration for [buildInfo] and [scheme].
  static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
373
    if (buildInfo.flavor == null) {
374
      return baseConfiguration;
375
    }
376
    return '$baseConfiguration-$scheme';
377 378
  }

379 380
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
381
  bool hasBuildConfigurationForBuildMode(String buildMode) {
382
    buildMode = buildMode.toLowerCase();
383
    for (final String name in buildConfigurations) {
384 385 386 387 388 389
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
390 391
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
392
  String? schemeFor(BuildInfo? buildInfo) {
393
    final String expectedScheme = expectedSchemeFor(buildInfo);
394
    if (schemes.contains(expectedScheme)) {
395
      return expectedScheme;
396
    }
397 398 399 400 401
    return _uniqueMatch(schemes, (String candidate) {
      return candidate.toLowerCase() == expectedScheme.toLowerCase();
    });
  }

402
  Never reportFlavorNotFoundAndExit() {
403 404 405 406 407 408 409 410 411
    _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.');
    }
  }

412 413
  /// Returns unique build configuration matching [buildInfo] and [scheme], or
  /// null, if there is no unique best match.
414 415 416 417
  String? buildConfigurationFor(BuildInfo? buildInfo, String scheme) {
    if (buildInfo == null) {
      return null;
    }
418
    final String expectedConfiguration = expectedBuildConfigurationFor(buildInfo, scheme);
419
    if (hasBuildConfigurationForBuildMode(expectedConfiguration)) {
420
      return expectedConfiguration;
421
    }
422 423 424
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
    return _uniqueMatch(buildConfigurations, (String candidate) {
      candidate = candidate.toLowerCase();
425
      if (buildInfo.flavor == null) {
426
        return candidate == expectedConfiguration.toLowerCase();
427 428
      }
      return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
429 430 431
    });
  }

432
  static String _baseConfigurationFor(BuildInfo buildInfo) {
433
    if (buildInfo.isDebug) {
434
      return 'Debug';
435 436
    }
    if (buildInfo.isProfile) {
437
      return 'Profile';
438
    }
439 440
    return 'Release';
  }
441

442
  static String? _uniqueMatch(Iterable<String> strings, bool Function(String s) matches) {
443
    final List<String> options = strings.where(matches).toList();
444
    if (options.length == 1) {
445
      return options.first;
446 447
    }
    return null;
448 449 450 451 452 453 454
  }

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