xcodeproj.dart 20.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 6
import 'dart:async';

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

11
import '../artifacts.dart';
12
import '../base/common.dart';
13
import '../base/file_system.dart';
14
import '../base/io.dart';
15
import '../base/logger.dart';
16
import '../base/process.dart';
17
import '../base/terminal.dart';
18
import '../base/utils.dart';
19
import '../build_info.dart';
20
import '../cache.dart';
21
import '../flutter_manifest.dart';
22
import '../globals.dart' as globals;
23
import '../project.dart';
24
import '../reporting/reporting.dart';
25

26 27
final RegExp _settingExpr = RegExp(r'(\w+)\s*=\s*(.*)$');
final RegExp _varExpr = RegExp(r'\$\(([^)]*)\)');
28

29
String flutterFrameworkDir(BuildMode mode) {
30
  return globals.fs.path.normalize(globals.fs.path.dirname(globals.artifacts.getArtifactPath(
31
      Artifact.flutterFramework, platform: TargetPlatform.ios, mode: mode)));
32 33
}

34
String flutterMacOSFrameworkDir(BuildMode mode) {
35
  return globals.fs.path.normalize(globals.fs.path.dirname(globals.artifacts.getArtifactPath(
36 37 38
      Artifact.flutterMacOSFramework, platform: TargetPlatform.darwin_x64, mode: mode)));
}

39
/// Writes or rewrites Xcode property files with the specified information.
40
///
41 42 43
/// useMacOSConfig: Optional parameter that controls whether we use the macOS
/// project file instead. Defaults to false.
///
44
/// setSymroot: Optional parameter to control whether to set SYMROOT.
45
///
46 47
/// targetOverride: Optional parameter, if null or unspecified the default value
/// from xcode_backend.sh is used 'lib/main.dart'.
48 49
Future<void> updateGeneratedXcodeProperties({
  @required FlutterProject project,
50
  @required BuildInfo buildInfo,
51
  String targetOverride,
52
  bool useMacOSConfig = false,
53
  bool setSymroot = true,
54
  String buildDirOverride,
55
}) async {
56 57 58 59 60
  final List<String> xcodeBuildSettings = _xcodeBuildSettingsLines(
    project: project,
    buildInfo: buildInfo,
    targetOverride: targetOverride,
    useMacOSConfig: useMacOSConfig,
61
    setSymroot: setSymroot,
62
    buildDirOverride: buildDirOverride,
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
  );

  _updateGeneratedXcodePropertiesFile(
    project: project,
    xcodeBuildSettings: xcodeBuildSettings,
    useMacOSConfig: useMacOSConfig,
  );

  _updateGeneratedEnvironmentVariablesScript(
    project: project,
    xcodeBuildSettings: xcodeBuildSettings,
    useMacOSConfig: useMacOSConfig,
  );
}

/// Generate a xcconfig file to inherit FLUTTER_ build settings
/// for Xcode targets that need them.
/// See [XcodeBasedProject.generatedXcodePropertiesFile].
void _updateGeneratedXcodePropertiesFile({
  @required FlutterProject project,
  @required List<String> xcodeBuildSettings,
  bool useMacOSConfig = false,
}) {
86
  final StringBuffer localsBuffer = StringBuffer();
87 88

  localsBuffer.writeln('// This is a generated file; do not edit or check into version control.');
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
  xcodeBuildSettings.forEach(localsBuffer.writeln);
  final File generatedXcodePropertiesFile = useMacOSConfig
    ? project.macos.generatedXcodePropertiesFile
    : project.ios.generatedXcodePropertiesFile;

  generatedXcodePropertiesFile.createSync(recursive: true);
  generatedXcodePropertiesFile.writeAsStringSync(localsBuffer.toString());
}

/// Generate a script to export all the FLUTTER_ environment variables needed
/// as flags for Flutter tools.
/// See [XcodeBasedProject.generatedEnvironmentVariableExportScript].
void _updateGeneratedEnvironmentVariablesScript({
  @required FlutterProject project,
  @required List<String> xcodeBuildSettings,
  bool useMacOSConfig = false,
}) {
  final StringBuffer localsBuffer = StringBuffer();

  localsBuffer.writeln('#!/bin/sh');
  localsBuffer.writeln('# This is a generated file; do not edit or check into version control.');
110
  for (final String line in xcodeBuildSettings) {
111 112 113 114 115 116 117 118
    localsBuffer.writeln('export "$line"');
  }

  final File generatedModuleBuildPhaseScript = useMacOSConfig
    ? project.macos.generatedEnvironmentVariableExportScript
    : project.ios.generatedEnvironmentVariableExportScript;
  generatedModuleBuildPhaseScript.createSync(recursive: true);
  generatedModuleBuildPhaseScript.writeAsStringSync(localsBuffer.toString());
119
  globals.os.chmod(generatedModuleBuildPhaseScript, '755');
120 121
}

122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
/// Build name parsed and validated from build info and manifest. Used for CFBundleShortVersionString.
String parsedBuildName({
  @required FlutterManifest manifest,
  @required BuildInfo buildInfo,
}) {
  final String buildNameToParse = buildInfo?.buildName ?? manifest.buildName;
  return validatedBuildNameForPlatform(TargetPlatform.ios, buildNameToParse);
}

/// Build number parsed and validated from build info and manifest. Used for CFBundleVersion.
String parsedBuildNumber({
  @required FlutterManifest manifest,
  @required BuildInfo buildInfo,
}) {
  String buildNumberToParse = buildInfo?.buildNumber ?? manifest.buildNumber;
  final String buildNumber = validatedBuildNumberForPlatform(TargetPlatform.ios, buildNumberToParse);
  if (buildNumber != null && buildNumber.isNotEmpty) {
    return buildNumber;
  }
  // Drop back to parsing build name if build number is not present. Build number is optional in the manifest, but
  // FLUTTER_BUILD_NUMBER is required as the backing value for the required CFBundleVersion.
  buildNumberToParse = buildInfo?.buildName ?? manifest.buildName;
  return validatedBuildNumberForPlatform(TargetPlatform.ios, buildNumberToParse);
}

147 148 149 150 151 152 153
/// List of lines of build settings. Example: 'FLUTTER_BUILD_DIR=build'
List<String> _xcodeBuildSettingsLines({
  @required FlutterProject project,
  @required BuildInfo buildInfo,
  String targetOverride,
  bool useMacOSConfig = false,
  bool setSymroot = true,
154
  String buildDirOverride,
155 156
}) {
  final List<String> xcodeBuildSettings = <String>[];
157

158
  final String flutterRoot = globals.fs.path.normalize(Cache.flutterRoot);
159
  xcodeBuildSettings.add('FLUTTER_ROOT=$flutterRoot');
160 161

  // This holds because requiresProjectRoot is true for this command
162
  xcodeBuildSettings.add('FLUTTER_APPLICATION_PATH=${globals.fs.path.normalize(project.directory.path)}');
163

164
  // Relative to FLUTTER_APPLICATION_PATH, which is [Directory.current].
165
  if (targetOverride != null) {
166
    xcodeBuildSettings.add('FLUTTER_TARGET=$targetOverride');
167
  }
168

169 170 171 172 173
  // This is an optional path to split debug info
  if (buildInfo.splitDebugInfoPath != null) {
    xcodeBuildSettings.add('SPLIT_DEBUG_INFO=${buildInfo.splitDebugInfoPath}');
  }

174 175 176 177 178
  // This is an optional path to obfuscate and output a mapping.
  if (buildInfo.dartObfuscation) {
    xcodeBuildSettings.add('DART_OBFUSCATION=true');
  }

179
  // The build outputs directory, relative to FLUTTER_APPLICATION_PATH.
180
  xcodeBuildSettings.add('FLUTTER_BUILD_DIR=${buildDirOverride ?? getBuildDirectory()}');
181

182
  if (setSymroot) {
183
    xcodeBuildSettings.add('SYMROOT=\${SOURCE_ROOT}/../${getIosBuildDirectory()}');
184
  }
185

186 187 188 189 190
  // iOS does not link on Flutter in any build phase. Add the linker flag.
  if (!useMacOSConfig) {
    xcodeBuildSettings.add('OTHER_LDFLAGS=\$(inherited) -framework Flutter');
  }

191 192 193 194
  if (!project.isModule) {
    // For module projects we do not want to write the FLUTTER_FRAMEWORK_DIR
    // explicitly. Rather we rely on the xcode backend script and the Podfile
    // logic to derive it from FLUTTER_ROOT and FLUTTER_BUILD_MODE.
195
    // However, this is necessary for regular projects using Cocoapods.
196
    final String frameworkDir = useMacOSConfig
197 198 199
      ? flutterMacOSFrameworkDir(buildInfo.mode)
      : flutterFrameworkDir(buildInfo.mode);
    xcodeBuildSettings.add('FLUTTER_FRAMEWORK_DIR=$frameworkDir');
200 201
  }

202

203
  final String buildName = parsedBuildName(manifest: project.manifest, buildInfo: buildInfo) ?? '1.0.0';
204
  xcodeBuildSettings.add('FLUTTER_BUILD_NAME=$buildName');
205 206

  final String buildNumber = parsedBuildNumber(manifest: project.manifest, buildInfo: buildInfo) ?? '1';
207 208
  xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber');

209 210
  if (globals.artifacts is LocalEngineArtifacts) {
    final LocalEngineArtifacts localEngineArtifacts = globals.artifacts as LocalEngineArtifacts;
211
    final String engineOutPath = localEngineArtifacts.engineOutPath;
212
    xcodeBuildSettings.add('FLUTTER_ENGINE=${globals.fs.path.dirname(globals.fs.path.dirname(engineOutPath))}');
213
    xcodeBuildSettings.add('LOCAL_ENGINE=${globals.fs.path.basename(engineOutPath)}');
214 215 216 217 218 219 220

    // Tell Xcode not to build universal binaries for local engines, which are
    // single-architecture.
    //
    // NOTE: this assumes that local engine binary paths are consistent with
    // the conventions uses in the engine: 32-bit iOS engines are built to
    // paths ending in _arm, 64-bit builds are not.
221 222 223 224
    //
    // Skip this step for macOS builds.
    if (!useMacOSConfig) {
      final String arch = engineOutPath.endsWith('_arm') ? 'armv7' : 'arm64';
225
      xcodeBuildSettings.add('ARCHS=$arch');
226
    }
227
  }
228

229
  if (buildInfo.trackWidgetCreation) {
230
    xcodeBuildSettings.add('TRACK_WIDGET_CREATION=true');
231 232
  }

233 234 235 236
  if (buildInfo.treeShakeIcons) {
    xcodeBuildSettings.add('TREE_SHAKE_ICONS=true');
  }

237
  if (buildInfo.dartDefines?.isNotEmpty ?? false) {
238
    xcodeBuildSettings.add('DART_DEFINES=${buildInfo.dartDefines.join(',')}');
239 240
  }

241
  if (buildInfo.extraFrontEndOptions?.isNotEmpty ?? false) {
242 243 244 245 246 247 248 249 250 251
    xcodeBuildSettings.add(
      'EXTRA_FRONT_END_OPTIONS='
      '${buildInfo.extraFrontEndOptions.join(',')}',
    );
  }
  if (buildInfo.extraGenSnapshotOptions?.isNotEmpty ?? false) {
    xcodeBuildSettings.add(
      'EXTRA_GEN_SNAPSHOT_OPTIONS='
      '${buildInfo.extraGenSnapshotOptions.join(',')}',
    );
252 253
  }

254
  return xcodeBuildSettings;
255
}
256

257
/// Interpreter of Xcode projects.
258
class XcodeProjectInterpreter {
259 260 261 262 263
  XcodeProjectInterpreter({
    @required Platform platform,
    @required ProcessManager processManager,
    @required Logger logger,
    @required FileSystem fileSystem,
264
    @required Terminal terminal,
265
    @required Usage usage,
266
  }) : _platform = platform,
267 268 269 270 271
      _fileSystem = fileSystem,
      _terminal = terminal,
      _logger = logger,
      _processUtils = ProcessUtils(logger: logger, processManager: processManager),
      _usage = usage;
272 273 274 275

  final Platform _platform;
  final FileSystem _fileSystem;
  final ProcessUtils _processUtils;
276
  final Terminal _terminal;
277
  final Logger _logger;
278
  final Usage _usage;
279

280
  static const String _executable = '/usr/bin/xcodebuild';
281
  static final RegExp _versionRegex = RegExp(r'Xcode ([0-9.]+)');
282

283
  void _updateVersion() {
284
    if (!_platform.isMacOS || !_fileSystem.file(_executable).existsSync()) {
285 286 287
      return;
    }
    try {
288
      final RunResult result = _processUtils.runSync(
289 290
        <String>[_executable, '-version'],
      );
291 292 293 294 295
      if (result.exitCode != 0) {
        return;
      }
      _versionText = result.stdout.trim().replaceAll('\n', ', ');
      final Match match = _versionRegex.firstMatch(versionText);
296
      if (match == null) {
297
        return;
298
      }
299 300 301 302 303
      final String version = match.group(1);
      final List<String> components = version.split('.');
      _majorVersion = int.parse(components[0]);
      _minorVersion = components.length == 1 ? 0 : int.parse(components[1]);
    } on ProcessException {
304
      // Ignored, leave values null.
305 306
    }
  }
307

308 309 310 311
  bool get isInstalled => majorVersion != null;

  String _versionText;
  String get versionText {
312
    if (_versionText == null) {
313
      _updateVersion();
314
    }
315 316 317 318 319
    return _versionText;
  }

  int _majorVersion;
  int get majorVersion {
320
    if (_majorVersion == null) {
321
      _updateVersion();
322
    }
323 324 325 326 327
    return _majorVersion;
  }

  int _minorVersion;
  int get minorVersion {
328
    if (_minorVersion == null) {
329
      _updateVersion();
330
    }
331 332
    return _minorVersion;
  }
333

334 335
  /// Asynchronously retrieve xcode build settings. This one is preferred for
  /// new call-sites.
336
  Future<Map<String, String>> getBuildSettings(
337 338
    String projectPath,
    String target, {
339 340 341
    Duration timeout = const Duration(minutes: 1),
  }) async {
    final Status status = Status.withSpinner(
342 343
      timeout: const TimeoutConfiguration().fastOperation,
      timeoutConfiguration: const TimeoutConfiguration(),
344
      stopwatch: Stopwatch(),
345
      terminal: _terminal,
346
    );
347 348 349
    final List<String> showBuildSettingsCommand = <String>[
      _executable,
      '-project',
350
      _fileSystem.path.absolute(projectPath),
351 352 353
      '-target',
      target,
      '-showBuildSettings',
354
      ...environmentVariablesAsXcodeBuildSettings(_platform)
355
    ];
356
    try {
357
      // showBuildSettings is reported to occasionally timeout. Here, we give it
358 359
      // a lot of wiggle room (locally on Flutter Gallery, this takes ~1s).
      // When there is a timeout, we retry once.
360
      final RunResult result = await _processUtils.run(
361
        showBuildSettingsCommand,
362
        throwOnError: true,
363 364 365 366 367 368
        workingDirectory: projectPath,
        timeout: timeout,
        timeoutRetries: 1,
      );
      final String out = result.stdout.trim();
      return parseXcodeBuildSettings(out);
369
    } on Exception catch (error) {
370 371 372
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
          command: showBuildSettingsCommand.join(' '),
373
          flutterUsage: _usage,
374 375
        ).send();
      }
376
      _logger.printTrace('Unexpected failure to get the build settings: $error.');
377 378 379 380 381 382
      return const <String, String>{};
    } finally {
      status.stop();
    }
  }

383
  Future<void> cleanWorkspace(String workspacePath, String scheme, { bool verbose = false }) async {
384
    await _processUtils.run(<String>[
385 386 387 388 389
      _executable,
      '-workspace',
      workspacePath,
      '-scheme',
      scheme,
390 391
      if (!verbose)
        '-quiet',
392
      'clean',
393 394
      ...environmentVariablesAsXcodeBuildSettings(_platform)
    ], workingDirectory: _fileSystem.currentDirectory.path);
395 396
  }

397
  Future<XcodeProjectInfo> getInfo(String projectPath, {String projectFilename}) async {
398 399 400 401
    // 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;
402
    final RunResult result = await _processUtils.run(
403 404 405 406 407 408
      <String>[
        _executable,
        '-list',
        if (projectFilename != null) ...<String>['-project', projectFilename],
      ],
      throwOnError: true,
409
      whiteListFailures: (int c) => c == missingProjectExitCode,
410 411
      workingDirectory: projectPath,
    );
412 413 414
    if (result.exitCode == missingProjectExitCode) {
      throwToolExit('Unable to get Xcode project information:\n ${result.stderr}');
    }
415
    return XcodeProjectInfo.fromXcodeBuildOutput(result.toString());
416
  }
xster's avatar
xster committed
417 418
}

419 420 421 422
/// 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.
423
List<String> environmentVariablesAsXcodeBuildSettings(Platform platform) {
424
  const String xcodeBuildSettingPrefix = 'FLUTTER_XCODE_';
425
  return platform.environment.entries.where((MapEntry<String, String> mapEntry) {
426 427 428 429 430 431 432 433
    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
434
Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
435
  final Map<String, String> settings = <String, String>{};
436
  for (final Match match in showBuildSettingsOutput.split('\n').map<Match>(_settingExpr.firstMatch)) {
437 438 439
    if (match != null) {
      settings[match[1]] = match[2];
    }
440 441 442 443 444 445
  }
  return settings;
}

/// Substitutes variables in [str] with their values from the specified Xcode
/// project and target.
446
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
447
  final Iterable<Match> matches = _varExpr.allMatches(str);
448
  if (matches.isEmpty) {
449
    return str;
450
  }
451

452
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]] ?? m[0]);
453
}
454 455 456 457 458 459 460 461 462 463 464 465

/// Information about an Xcode project.
///
/// Represents the output of `xcodebuild -list`.
class XcodeProjectInfo {
  XcodeProjectInfo(this.targets, this.buildConfigurations, this.schemes);

  factory XcodeProjectInfo.fromXcodeBuildOutput(String output) {
    final List<String> targets = <String>[];
    final List<String> buildConfigurations = <String>[];
    final List<String> schemes = <String>[];
    List<String> collector;
466
    for (final String line in output.split('\n')) {
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481
      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());
    }
482
    if (schemes.isEmpty) {
483
      schemes.add('Runner');
484
    }
485
    return XcodeProjectInfo(targets, buildConfigurations, schemes);
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507
  }

  final List<String> targets;
  final List<String> buildConfigurations;
  final List<String> schemes;

  bool get definesCustomTargets => !(targets.contains('Runner') && targets.length == 1);
  bool get definesCustomSchemes => !(schemes.contains('Runner') && schemes.length == 1);
  bool get definesCustomBuildConfigurations {
    return !(buildConfigurations.contains('Debug') &&
        buildConfigurations.contains('Release') &&
        buildConfigurations.length == 2);
  }

  /// The expected scheme for [buildInfo].
  static String expectedSchemeFor(BuildInfo buildInfo) {
    return toTitleCase(buildInfo.flavor ?? 'runner');
  }

  /// The expected build configuration for [buildInfo] and [scheme].
  static String expectedBuildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
508
    if (buildInfo.flavor == null) {
509
      return baseConfiguration;
510 511
    }
    return baseConfiguration + '-$scheme';
512 513
  }

514 515 516 517
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
  bool hasBuildConfiguratinForBuildMode(String buildMode) {
    buildMode = buildMode.toLowerCase();
518
    for (final String name in buildConfigurations) {
519 520 521 522 523 524
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
525 526 527 528
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
  String schemeFor(BuildInfo buildInfo) {
    final String expectedScheme = expectedSchemeFor(buildInfo);
529
    if (schemes.contains(expectedScheme)) {
530
      return expectedScheme;
531
    }
532 533 534 535 536 537 538 539 540
    return _uniqueMatch(schemes, (String candidate) {
      return candidate.toLowerCase() == expectedScheme.toLowerCase();
    });
  }

  /// Returns unique build configuration matching [buildInfo] and [scheme], or
  /// null, if there is no unique best match.
  String buildConfigurationFor(BuildInfo buildInfo, String scheme) {
    final String expectedConfiguration = expectedBuildConfigurationFor(buildInfo, scheme);
541
    if (hasBuildConfiguratinForBuildMode(expectedConfiguration)) {
542
      return expectedConfiguration;
543
    }
544 545 546
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
    return _uniqueMatch(buildConfigurations, (String candidate) {
      candidate = candidate.toLowerCase();
547
      if (buildInfo.flavor == null) {
548
        return candidate == expectedConfiguration.toLowerCase();
549 550
      }
      return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
551 552 553
    });
  }

554
  static String _baseConfigurationFor(BuildInfo buildInfo) {
555
    if (buildInfo.isDebug) {
556
      return 'Debug';
557 558
    }
    if (buildInfo.isProfile) {
559
      return 'Profile';
560
    }
561 562
    return 'Release';
  }
563 564 565

  static String _uniqueMatch(Iterable<String> strings, bool matches(String s)) {
    final List<String> options = strings.where(matches).toList();
566
    if (options.length == 1) {
567
      return options.first;
568 569
    }
    return null;
570 571 572 573 574 575 576
  }

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