xcodeproj.dart 17.6 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
        _versionText = version?.toString(),
62 63 64 65 66 67 68
        _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].
69
  /// Set [version] to null to simulate Xcode not being installed.
70
  factory XcodeProjectInterpreter.test({
71 72
    required ProcessManager processManager,
    Version? version = const Version.withText(1000, 0, 0, '1000.0.0'),
73 74 75 76 77 78 79 80 81
  }) {
    final Platform platform = FakePlatform(
      operatingSystem: 'macos',
      environment: <String, String>{},
    );
    return XcodeProjectInterpreter._(
      fileSystem: MemoryFileSystem.test(),
      platform: platform,
      processManager: processManager,
82
      usage: TestUsage(),
83
      logger: BufferLogger.test(),
84
      version: version,
85 86
    );
  }
87 88 89 90

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

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

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

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

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

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

144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
  /// 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;
  }

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

227 228 229 230 231 232 233 234 235 236 237 238
  /// 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();
239
    final String buildDirectory = _fileSystem.path.absolute(getIosBuildDirectory());
240 241 242 243 244 245 246 247 248
    final List<String> showBuildSettingsCommand = <String>[
      ...xcrunCommand(),
      'xcodebuild',
      '-alltargets',
      '-sdk',
      'iphonesimulator',
      '-project',
      podXcodeProject.path,
      '-showBuildSettings',
249 250
      'BUILD_DIR=$buildDirectory',
      'OBJROOT=$buildDirectory',
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
    ];
    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();
    }
  }

282
  Future<void> cleanWorkspace(String workspacePath, String scheme, { bool verbose = false }) async {
283
    await _processUtils.run(<String>[
284 285
      ...xcrunCommand(),
      'xcodebuild',
286 287 288 289
      '-workspace',
      workspacePath,
      '-scheme',
      scheme,
290 291
      if (!verbose)
        '-quiet',
292
      'clean',
293 294
      ...environmentVariablesAsXcodeBuildSettings(_platform)
    ], workingDirectory: _fileSystem.currentDirectory.path);
295 296
  }

297
  Future<XcodeProjectInfo> getInfo(String projectPath, {String? projectFilename}) async {
298 299 300 301
    // 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;
302 303 304
    // The exit code returned by 'xcodebuild -list' when the project is corrupted.
    const int corruptedProjectExitCode = 74;
    bool _allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode;
305
    final RunResult result = await _processUtils.run(
306
      <String>[
307 308
        ...xcrunCommand(),
        'xcodebuild',
309 310 311 312
        '-list',
        if (projectFilename != null) ...<String>['-project', projectFilename],
      ],
      throwOnError: true,
313
      allowedFailures: _allowedFailures,
314 315
      workingDirectory: projectPath,
    );
316 317
    if (_allowedFailures(result.exitCode)) {
      // User configuration error, tool exit instead of crashing.
318 319
      throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
    }
320
    return XcodeProjectInfo.fromXcodeBuildOutput(result.toString(), _logger);
321
  }
xster's avatar
xster committed
322 323
}

324 325 326 327
/// 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.
328
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
329
  const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
330
  return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
331 332 333 334 335 336 337 338
    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
339
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
340
  final Map<String, String> settings = <String, String>{};
341
  for (final Match? match in showBuildSettingsOutput.split('\n').map<Match?>(_settingExpr.firstMatch)) {
342
    if (match != null) {
343
      settings[match[1]!] = match[2]!;
344
    }
345 346 347 348 349 350
  }
  return settings;
}

/// Substitutes variables in [str] with their values from the specified Xcode
/// project and target.
351
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
352
  final Iterable<Match> matches = _varExpr.allMatches(str);
353
  if (matches.isEmpty) {
354
    return str;
355
  }
356

357
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!);
358
}
359

360 361
@immutable
class XcodeProjectBuildContext {
362 363 364 365 366 367
  const XcodeProjectBuildContext({
    this.scheme,
    this.configuration,
    this.environmentType = EnvironmentType.physical,
    this.deviceId,
  });
368 369 370 371

  final String? scheme;
  final String? configuration;
  final EnvironmentType environmentType;
372
  final String? deviceId;
373 374

  @override
375
  int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId);
376 377 378 379 380 381 382 383 384

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

390 391 392 393
/// Information about an Xcode project.
///
/// Represents the output of `xcodebuild -list`.
class XcodeProjectInfo {
394
  const XcodeProjectInfo(
395 396 397 398 399 400 401
    this.targets,
    this.buildConfigurations,
    this.schemes,
    Logger logger
  ) : _logger = logger;

  factory XcodeProjectInfo.fromXcodeBuildOutput(String output, Logger logger) {
402 403 404
    final List<String> targets = <String>[];
    final List<String> buildConfigurations = <String>[];
    final List<String> schemes = <String>[];
405
    List<String>? collector;
406
    for (final String line in output.split('\n')) {
407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
      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());
    }
422
    if (schemes.isEmpty) {
423
      schemes.add('Runner');
424
    }
425
    return XcodeProjectInfo(targets, buildConfigurations, schemes, logger);
426 427 428 429 430
  }

  final List<String> targets;
  final List<String> buildConfigurations;
  final List<String> schemes;
431
  final Logger _logger;
432 433 434 435

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

  /// The expected scheme for [buildInfo].
436
  @visibleForTesting
437
  static String expectedSchemeFor(BuildInfo? buildInfo) {
438
    return sentenceCase(buildInfo?.flavor ?? 'runner');
439 440 441 442 443
  }

  /// The expected build configuration for [buildInfo] and [scheme].
  static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
444
    if (buildInfo.flavor == null) {
445
      return baseConfiguration;
446
    }
447
    return '$baseConfiguration-$scheme';
448 449
  }

450 451
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
452
  bool hasBuildConfigurationForBuildMode(String buildMode) {
453
    buildMode = buildMode.toLowerCase();
454
    for (final String name in buildConfigurations) {
455 456 457 458 459 460
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
461 462
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
463
  String? schemeFor(BuildInfo? buildInfo) {
464
    final String expectedScheme = expectedSchemeFor(buildInfo);
465
    if (schemes.contains(expectedScheme)) {
466
      return expectedScheme;
467
    }
468 469 470 471 472
    return _uniqueMatch(schemes, (String candidate) {
      return candidate.toLowerCase() == expectedScheme.toLowerCase();
    });
  }

473
  Never reportFlavorNotFoundAndExit() {
474 475 476 477 478 479 480 481 482
    _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.');
    }
  }

483 484
  /// Returns unique build configuration matching [buildInfo] and [scheme], or
  /// null, if there is no unique best match.
485 486 487 488
  String? buildConfigurationFor(BuildInfo? buildInfo, String scheme) {
    if (buildInfo == null) {
      return null;
    }
489
    final String expectedConfiguration = expectedBuildConfigurationFor(buildInfo, scheme);
490
    if (hasBuildConfigurationForBuildMode(expectedConfiguration)) {
491
      return expectedConfiguration;
492
    }
493 494 495
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
    return _uniqueMatch(buildConfigurations, (String candidate) {
      candidate = candidate.toLowerCase();
496
      if (buildInfo.flavor == null) {
497
        return candidate == expectedConfiguration.toLowerCase();
498 499
      }
      return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
500 501 502
    });
  }

503
  static String _baseConfigurationFor(BuildInfo buildInfo) {
504
    if (buildInfo.isDebug) {
505
      return 'Debug';
506 507
    }
    if (buildInfo.isProfile) {
508
      return 'Profile';
509
    }
510 511
    return 'Release';
  }
512

513
  static String? _uniqueMatch(Iterable<String> strings, bool Function(String s) matches) {
514
    final List<String> options = strings.where(matches).toList();
515
    if (options.length == 1) {
516
      return options.first;
517 518
    }
    return null;
519 520 521 522 523 524 525
  }

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