xcodeproj.dart 16.5 KB
Newer Older
1 2 3 4
// Copyright 2016 The Chromium Authors. All rights reserved.
// 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 8
import 'package:meta/meta.dart';

9
import '../artifacts.dart';
10
import '../base/context.dart';
11
import '../base/file_system.dart';
12
import '../base/io.dart';
13
import '../base/logger.dart';
14
import '../base/os.dart';
15
import '../base/platform.dart';
16
import '../base/process.dart';
17
import '../base/utils.dart';
18
import '../build_info.dart';
19
import '../cache.dart';
20
import '../globals.dart';
21
import '../project.dart';
22
import '../reporting/reporting.dart';
23

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

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

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

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

  _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,
}) {
84
  final StringBuffer localsBuffer = StringBuffer();
85 86

  localsBuffer.writeln('// This is a generated file; do not edit or check into version control.');
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
  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.');
  for (String line in xcodeBuildSettings) {
    localsBuffer.writeln('export "$line"');
  }

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

/// 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,
127
  String buildDirOverride,
128 129
}) {
  final List<String> xcodeBuildSettings = <String>[];
130

131
  final String flutterRoot = fs.path.normalize(Cache.flutterRoot);
132
  xcodeBuildSettings.add('FLUTTER_ROOT=$flutterRoot');
133 134

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

137
  // Relative to FLUTTER_APPLICATION_PATH, which is [Directory.current].
138
  if (targetOverride != null)
139
    xcodeBuildSettings.add('FLUTTER_TARGET=$targetOverride');
140

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

144
  if (setSymroot) {
145
    xcodeBuildSettings.add('SYMROOT=\${SOURCE_ROOT}/../${getIosBuildDirectory()}');
146
  }
147

148 149 150 151
  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.
152
    // However, this is necessary for regular projects using Cocoapods.
153
    final String frameworkDir = useMacOSConfig
154 155 156
      ? flutterMacOSFrameworkDir(buildInfo.mode)
      : flutterFrameworkDir(buildInfo.mode);
    xcodeBuildSettings.add('FLUTTER_FRAMEWORK_DIR=$frameworkDir');
157 158
  }

159 160
  final String buildNameToParse = buildInfo?.buildName ?? project.manifest.buildName;
  final String buildName = validatedBuildNameForPlatform(TargetPlatform.ios, buildNameToParse);
161
  if (buildName != null) {
162
    xcodeBuildSettings.add('FLUTTER_BUILD_NAME=$buildName');
163 164
  }

165 166 167 168 169
  String buildNumber = validatedBuildNumberForPlatform(TargetPlatform.ios, buildInfo?.buildNumber ?? project.manifest.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.
  buildNumber ??= validatedBuildNumberForPlatform(TargetPlatform.ios, buildNameToParse);

170
  if (buildNumber != null) {
171
    xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber');
172 173
  }

174
  if (artifacts is LocalEngineArtifacts) {
175
    final LocalEngineArtifacts localEngineArtifacts = artifacts;
176
    final String engineOutPath = localEngineArtifacts.engineOutPath;
177 178
    xcodeBuildSettings.add('FLUTTER_ENGINE=${fs.path.dirname(fs.path.dirname(engineOutPath))}');
    xcodeBuildSettings.add('LOCAL_ENGINE=${fs.path.basename(engineOutPath)}');
179 180 181 182 183 184 185

    // 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.
186 187 188 189
    //
    // Skip this step for macOS builds.
    if (!useMacOSConfig) {
      final String arch = engineOutPath.endsWith('_arm') ? 'armv7' : 'arm64';
190
      xcodeBuildSettings.add('ARCHS=$arch');
191
    }
192
  }
193

194
  if (buildInfo.trackWidgetCreation) {
195
    xcodeBuildSettings.add('TRACK_WIDGET_CREATION=true');
196 197
  }

198
  return xcodeBuildSettings;
199
}
200

201
XcodeProjectInterpreter get xcodeProjectInterpreter => context.get<XcodeProjectInterpreter>();
202

203
/// Interpreter of Xcode projects.
204 205
class XcodeProjectInterpreter {
  static const String _executable = '/usr/bin/xcodebuild';
206
  static final RegExp _versionRegex = RegExp(r'Xcode ([0-9.]+)');
207

208 209 210 211 212
  void _updateVersion() {
    if (!platform.isMacOS || !fs.file(_executable).existsSync()) {
      return;
    }
    try {
213 214 215
      final RunResult result = processUtils.runSync(
        <String>[_executable, '-version'],
      );
216 217 218 219 220 221 222 223 224 225 226 227
      if (result.exitCode != 0) {
        return;
      }
      _versionText = result.stdout.trim().replaceAll('\n', ', ');
      final Match match = _versionRegex.firstMatch(versionText);
      if (match == null)
        return;
      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 {
228
      // Ignored, leave values null.
229 230
    }
  }
231

232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253
  bool get isInstalled => majorVersion != null;

  String _versionText;
  String get versionText {
    if (_versionText == null)
      _updateVersion();
    return _versionText;
  }

  int _majorVersion;
  int get majorVersion {
    if (_majorVersion == null)
      _updateVersion();
    return _majorVersion;
  }

  int _minorVersion;
  int get minorVersion {
    if (_minorVersion == null)
      _updateVersion();
    return _minorVersion;
  }
254

255 256
  /// Synchronously retrieve xcode build settings. Prefer using the async
  /// version below.
257
  Map<String, String> getBuildSettings(String projectPath, String target) {
258
    try {
259 260 261 262 263 264 265 266 267 268 269 270
      final String out = processUtils.runSync(
        <String>[
          _executable,
          '-project',
          fs.path.absolute(projectPath),
          '-target',
          target,
          '-showBuildSettings',
        ],
        throwOnError: true,
        workingDirectory: projectPath,
      ).stdout.trim();
271
      return parseXcodeBuildSettings(out);
272
    } on ProcessException catch (error) {
273 274 275
      printTrace('Unexpected failure to get the build settings: $error.');
      return const <String, String>{};
    }
276 277
  }

278 279 280 281 282 283 284 285 286
  /// Asynchronously retrieve xcode build settings. This one is preferred for
  /// new call-sites.
  Future<Map<String, String>> getBuildSettingsAsync(
      String projectPath, String target, {
    Duration timeout = const Duration(minutes: 1),
  }) async {
    final Status status = Status.withSpinner(
      timeout: timeoutConfiguration.fastOperation,
    );
287 288 289 290 291 292 293 294
    final List<String> showBuildSettingsCommand = <String>[
      _executable,
      '-project',
      fs.path.absolute(projectPath),
      '-target',
      target,
      '-showBuildSettings',
    ];
295 296 297 298
    try {
      // showBuildSettings is reported to ocassionally 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.
299
      final RunResult result = await processUtils.run(
300
        showBuildSettingsCommand,
301
        throwOnError: true,
302 303 304 305 306 307 308
        workingDirectory: projectPath,
        timeout: timeout,
        timeoutRetries: 1,
      );
      final String out = result.stdout.trim();
      return parseXcodeBuildSettings(out);
    } catch(error) {
309 310 311 312 313
      if (error is ProcessException && error.toString().contains('timed out')) {
        BuildEvent('xcode-show-build-settings-timeout',
          command: showBuildSettingsCommand.join(' '),
        ).send();
      }
314 315 316 317 318 319 320
      printTrace('Unexpected failure to get the build settings: $error.');
      return const <String, String>{};
    } finally {
      status.stop();
    }
  }

321
  void cleanWorkspace(String workspacePath, String scheme) {
322
    processUtils.runSync(<String>[
323 324 325 326 327 328 329 330 331 332
      _executable,
      '-workspace',
      workspacePath,
      '-scheme',
      scheme,
      '-quiet',
      'clean'
    ], workingDirectory: fs.currentDirectory.path);
  }

333
  Future<XcodeProjectInfo> getInfo(String projectPath, {String projectFilename}) async {
334 335 336 337 338 339 340 341 342
    final RunResult result = await processUtils.run(
      <String>[
        _executable,
        '-list',
        if (projectFilename != null) ...<String>['-project', projectFilename],
      ],
      throwOnError: true,
      workingDirectory: projectPath,
    );
343
    return XcodeProjectInfo.fromXcodeBuildOutput(result.toString());
344
  }
xster's avatar
xster committed
345 346 347
}

Map<String, String> parseXcodeBuildSettings(String showBuildSettingsOutput) {
348
  final Map<String, String> settings = <String, String>{};
349
  for (Match match in showBuildSettingsOutput.split('\n').map<Match>(_settingExpr.firstMatch)) {
350 351 352
    if (match != null) {
      settings[match[1]] = match[2];
    }
353 354 355 356 357 358
  }
  return settings;
}

/// Substitutes variables in [str] with their values from the specified Xcode
/// project and target.
359
String substituteXcodeVariables(String str, Map<String, String> xcodeBuildSettings) {
360
  final Iterable<Match> matches = _varExpr.allMatches(str);
361 362 363
  if (matches.isEmpty)
    return str;

364
  return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]] ?? m[0]);
365
}
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393

/// 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;
    for (String line in output.split('\n')) {
      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());
    }
394 395
    if (schemes.isEmpty)
      schemes.add('Runner');
396
    return XcodeProjectInfo(targets, buildConfigurations, schemes);
397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424
  }

  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);
    if (buildInfo.flavor == null)
      return baseConfiguration;
    else
      return baseConfiguration + '-$scheme';
  }

425 426 427 428 429 430 431 432 433 434 435
  /// Checks whether the [buildConfigurations] contains the specified string, without
  /// regard to case.
  bool hasBuildConfiguratinForBuildMode(String buildMode) {
    buildMode = buildMode.toLowerCase();
    for (String name in buildConfigurations) {
      if (name.toLowerCase() == buildMode) {
        return true;
      }
    }
    return false;
  }
436 437 438 439 440 441 442 443 444 445 446 447 448 449 450
  /// Returns unique scheme matching [buildInfo], or null, if there is no unique
  /// best match.
  String schemeFor(BuildInfo buildInfo) {
    final String expectedScheme = expectedSchemeFor(buildInfo);
    if (schemes.contains(expectedScheme))
      return expectedScheme;
    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);
451
    if (hasBuildConfiguratinForBuildMode(expectedConfiguration))
452 453 454 455 456 457 458 459 460 461 462
      return expectedConfiguration;
    final String baseConfiguration = _baseConfigurationFor(buildInfo);
    return _uniqueMatch(buildConfigurations, (String candidate) {
      candidate = candidate.toLowerCase();
      if (buildInfo.flavor == null)
        return candidate == expectedConfiguration.toLowerCase();
      else
        return candidate.contains(baseConfiguration.toLowerCase()) && candidate.contains(scheme.toLowerCase());
    });
  }

463 464 465 466 467 468 469
  static String _baseConfigurationFor(BuildInfo buildInfo) {
    if (buildInfo.isDebug)
      return 'Debug';
    if (buildInfo.isProfile)
      return 'Profile';
    return 'Release';
  }
470 471 472 473 474 475 476 477 478 479 480 481 482 483

  static String _uniqueMatch(Iterable<String> strings, bool matches(String s)) {
    final List<String> options = strings.where(matches).toList();
    if (options.length == 1)
      return options.first;
    else
      return null;
  }

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