xcode_project.dart 21.8 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'artifacts.dart';
import 'base/error_handling_io.dart';
import 'base/file_system.dart';
import 'base/utils.dart';
import 'build_info.dart';
import 'bundle.dart' as bundle;
import 'flutter_plugins.dart';
12
import 'globals.dart' as globals;
13
import 'ios/code_signing.dart';
14 15 16 17 18 19 20 21 22 23
import 'ios/plist_parser.dart';
import 'ios/xcode_build_settings.dart' as xcode;
import 'ios/xcodeproj.dart';
import 'platform_plugins.dart';
import 'project.dart';
import 'template.dart';

/// Represents an Xcode-based sub-project.
///
/// This defines interfaces common to iOS and macOS projects.
24 25 26
abstract class XcodeBasedProject extends FlutterProjectPlatform  {
  static const String _hostAppProjectName = 'Runner';

27 28 29
  /// The parent of this project.
  FlutterProject get parent;

30 31 32 33
  Directory get hostAppRoot;

  /// The default 'Info.plist' file of the host app. The developer can change this location in Xcode.
  File get defaultHostInfoPlist => hostAppRoot.childDirectory(_hostAppProjectName).childFile('Info.plist');
34 35

  /// The Xcode project (.xcodeproj directory) of the host app.
36
  Directory get xcodeProject => hostAppRoot.childDirectory('$_hostAppProjectName.xcodeproj');
37 38

  /// The 'project.pbxproj' file of [xcodeProject].
39 40
  File get xcodeProjectInfoFile => xcodeProject.childFile('project.pbxproj');

41 42 43 44
  /// The 'Runner.xcscheme' file of [xcodeProject].
  File get xcodeProjectSchemeFile =>
      xcodeProject.childDirectory('xcshareddata').childDirectory('xcschemes').childFile('Runner.xcscheme');

45 46 47 48
  File get xcodeProjectWorkspaceData =>
      xcodeProject
          .childDirectory('project.xcworkspace')
          .childFile('contents.xcworkspacedata');
49 50

  /// The Xcode workspace (.xcworkspace directory) of the host app.
51 52 53 54 55 56 57
  Directory get xcodeWorkspace => hostAppRoot.childDirectory('$_hostAppProjectName.xcworkspace');

  /// Xcode workspace shared data directory for the host app.
  Directory get xcodeWorkspaceSharedData => xcodeWorkspace.childDirectory('xcshareddata');

  /// Xcode workspace shared workspace settings file for the host app.
  File get xcodeWorkspaceSharedSettings => xcodeWorkspaceSharedData.childFile('WorkspaceSettings.xcsettings');
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72

  /// Contains definitions for FLUTTER_ROOT, LOCAL_ENGINE, and more flags for
  /// the Xcode build.
  File get generatedXcodePropertiesFile;

  /// The Flutter-managed Xcode config file for [mode].
  File xcodeConfigFor(String mode);

  /// The script that exports environment variables needed for Flutter tools.
  /// Can be run first in a Xcode Script build phase to make FLUTTER_ROOT,
  /// LOCAL_ENGINE, and other Flutter variables available to any flutter
  /// tooling (`flutter build`, etc) to convert into flags.
  File get generatedEnvironmentVariableExportScript;

  /// The CocoaPods 'Podfile'.
73
  File get podfile => hostAppRoot.childFile('Podfile');
74 75

  /// The CocoaPods 'Podfile.lock'.
76
  File get podfileLock => hostAppRoot.childFile('Podfile.lock');
77 78

  /// The CocoaPods 'Manifest.lock'.
79
  File get podManifestLock => hostAppRoot.childDirectory('Pods').childFile('Manifest.lock');
80 81 82 83 84 85
}

/// Represents the iOS sub-project of a Flutter project.
///
/// Instances will reflect the contents of the `ios/` sub-folder of
/// Flutter applications and the `.ios/` sub-folder of Flutter module projects.
86
class IosProject extends XcodeBasedProject {
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
  IosProject.fromFlutter(this.parent);

  @override
  final FlutterProject parent;

  @override
  String get pluginConfigKey => IOSPlugin.kConfigKey;

  static final RegExp _productBundleIdPattern = RegExp(r'''^\s*PRODUCT_BUNDLE_IDENTIFIER\s*=\s*(["']?)(.*?)\1;\s*$''');
  static const String _productBundleIdVariable = r'$(PRODUCT_BUNDLE_IDENTIFIER)';

  Directory get ephemeralModuleDirectory => parent.directory.childDirectory('.ios');
  Directory get _editableDirectory => parent.directory.childDirectory('ios');

  /// This parent folder of `Runner.xcodeproj`.
102
  @override
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
  Directory get hostAppRoot {
    if (!isModule || _editableDirectory.existsSync()) {
      return _editableDirectory;
    }
    return ephemeralModuleDirectory;
  }

  /// The root directory of the iOS wrapping of Flutter and plugins. This is the
  /// parent of the `Flutter/` folder into which Flutter artifacts are written
  /// during build.
  ///
  /// This is the same as [hostAppRoot] except when the project is
  /// a Flutter module with an editable host app.
  Directory get _flutterLibRoot => isModule ? ephemeralModuleDirectory : _editableDirectory;

  /// True, if the parent Flutter project is a module project.
  bool get isModule => parent.isModule;

121
  /// Whether the Flutter application has an iOS project.
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
  bool get exists => hostAppRoot.existsSync();

  /// Put generated files here.
  Directory get ephemeralDirectory => _flutterLibRoot.childDirectory('Flutter').childDirectory('ephemeral');

  @override
  File xcodeConfigFor(String mode) => _flutterLibRoot.childDirectory('Flutter').childFile('$mode.xcconfig');

  @override
  File get generatedEnvironmentVariableExportScript => _flutterLibRoot.childDirectory('Flutter').childFile('flutter_export_environment.sh');

  File get appFrameworkInfoPlist => _flutterLibRoot.childDirectory('Flutter').childFile('AppFrameworkInfo.plist');

  Directory get symlinks => _flutterLibRoot.childDirectory('.symlinks');

137 138 139 140 141 142
  /// True, if the app project is using swift.
  bool get isSwift {
    final File appDelegateSwift = _editableDirectory.childDirectory('Runner').childFile('AppDelegate.swift');
    return appDelegateSwift.existsSync();
  }

143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
  /// Do all plugins support arm64 simulators to run natively on an ARM Mac?
  Future<bool> pluginsSupportArmSimulator() async {
    final Directory podXcodeProject = hostAppRoot
        .childDirectory('Pods')
        .childDirectory('Pods.xcodeproj');
    if (!podXcodeProject.existsSync()) {
      // No plugins.
      return true;
    }

    final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter;
    if (xcodeProjectInterpreter == null) {
      // Xcode isn't installed, don't try to check.
      return false;
    }
    final String? buildSettings = await xcodeProjectInterpreter.pluginsBuildSettingsOutput(podXcodeProject);

    // See if any plugins or their dependencies exclude arm64 simulators
    // as a valid architecture, usually because a binary is missing that slice.
    // Example: EXCLUDED_ARCHS = arm64 i386
    // NOT: EXCLUDED_ARCHS = i386
    return buildSettings != null && !buildSettings.contains(RegExp('EXCLUDED_ARCHS.*arm64'));
  }

167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
  @override
  bool existsSync()  {
    return parent.isModule || _editableDirectory.existsSync();
  }

  /// The product bundle identifier of the host app, or null if not set or if
  /// iOS tooling needed to read it is not installed.
  Future<String?> productBundleIdentifier(BuildInfo? buildInfo) async {
    if (!existsSync()) {
      return null;
    }
    return _productBundleIdentifier ??= await _parseProductBundleIdentifier(buildInfo);
  }
  String? _productBundleIdentifier;

  Future<String?> _parseProductBundleIdentifier(BuildInfo? buildInfo) async {
    String? fromPlist;
    final File defaultInfoPlist = defaultHostInfoPlist;
    // Users can change the location of the Info.plist.
    // Try parsing the default, first.
    if (defaultInfoPlist.existsSync()) {
      try {
189
        fromPlist = globals.plistParser.getStringValueFromFile(
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
          defaultHostInfoPlist.path,
          PlistParser.kCFBundleIdentifierKey,
        );
      } on FileNotFoundException {
        // iOS tooling not found; likely not running OSX; let [fromPlist] be null
      }
      if (fromPlist != null && !fromPlist.contains(r'$')) {
        // Info.plist has no build variables in product bundle ID.
        return fromPlist;
      }
    }
    final Map<String, String>? allBuildSettings = await buildSettingsForBuildInfo(buildInfo);
    if (allBuildSettings != null) {
      if (fromPlist != null) {
        // Perform variable substitution using build settings.
        return substituteXcodeVariables(fromPlist, allBuildSettings);
      }
      return allBuildSettings['PRODUCT_BUNDLE_IDENTIFIER'];
    }

    // On non-macOS platforms, parse the first PRODUCT_BUNDLE_IDENTIFIER from
    // the project file. This can return the wrong bundle identifier if additional
    // bundles have been added to the project and are found first, like frameworks
    // or companion watchOS projects. However, on non-macOS platforms this is
    // only used for display purposes and to regenerate organization names, so
    // best-effort is probably fine.
    final String? fromPbxproj = firstMatchInFile(xcodeProjectInfoFile, _productBundleIdPattern)?.group(2);
    if (fromPbxproj != null && (fromPlist == null || fromPlist == _productBundleIdVariable)) {
      return fromPbxproj;
    }

    return null;
  }

  /// The bundle name of the host app, `My App.app`.
225
  Future<String?> hostAppBundleName(BuildInfo? buildInfo) async {
226 227 228 229 230 231 232
    if (!existsSync()) {
      return null;
    }
    return _hostAppBundleName ??= await _parseHostAppBundleName(buildInfo);
  }
  String? _hostAppBundleName;

233
  Future<String> _parseHostAppBundleName(BuildInfo? buildInfo) async {
234 235 236 237 238
    // The product name and bundle name are derived from the display name, which the user
    // is instructed to change in Xcode as part of deploying to the App Store.
    // https://flutter.dev/docs/deployment/ios#review-xcode-project-settings
    // The only source of truth for the name is Xcode's interpretation of the build settings.
    String? productName;
239
    if (globals.xcodeProjectInterpreter?.isInstalled ?? false) {
240 241 242 243 244 245
      final Map<String, String>? xcodeBuildSettings = await buildSettingsForBuildInfo(buildInfo);
      if (xcodeBuildSettings != null) {
        productName = xcodeBuildSettings['FULL_PRODUCT_NAME'];
      }
    }
    if (productName == null) {
246
      globals.printTrace('FULL_PRODUCT_NAME not present, defaulting to ${XcodeBasedProject._hostAppProjectName}');
247
    }
248
    return productName ?? '${XcodeBasedProject._hostAppProjectName}.app';
249 250 251 252 253
  }

  /// The build settings for the host app of this project, as a detached map.
  ///
  /// Returns null, if iOS tooling is unavailable.
254 255 256 257 258
  Future<Map<String, String>?> buildSettingsForBuildInfo(
    BuildInfo? buildInfo, {
    EnvironmentType environmentType = EnvironmentType.physical,
    String? deviceId,
  }) async {
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
    if (!existsSync()) {
      return null;
    }
    final XcodeProjectInfo? info = await projectInfo();
    if (info == null) {
      return null;
    }

    final String? scheme = info.schemeFor(buildInfo);
    if (scheme == null) {
      info.reportFlavorNotFoundAndExit();
    }

    final String? configuration = (await projectInfo())?.buildConfigurationFor(
      buildInfo,
      scheme,
    );
276 277 278 279 280 281
    final XcodeProjectBuildContext buildContext = XcodeProjectBuildContext(
      environmentType: environmentType,
      scheme: scheme,
      configuration: configuration,
      deviceId: deviceId,
    );
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328
    final Map<String, String>? currentBuildSettings = _buildSettingsByBuildContext[buildContext];
    if (currentBuildSettings == null) {
      final Map<String, String>? calculatedBuildSettings = await _xcodeProjectBuildSettings(buildContext);
      if (calculatedBuildSettings != null) {
        _buildSettingsByBuildContext[buildContext] = calculatedBuildSettings;
      }
    }
    return _buildSettingsByBuildContext[buildContext];
  }

  final Map<XcodeProjectBuildContext, Map<String, String>> _buildSettingsByBuildContext = <XcodeProjectBuildContext, Map<String, String>>{};

  Future<XcodeProjectInfo?> projectInfo() async {
    final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter;
    if (!xcodeProject.existsSync() || xcodeProjectInterpreter == null || !xcodeProjectInterpreter.isInstalled) {
      return null;
    }
    return _projectInfo ??= await xcodeProjectInterpreter.getInfo(hostAppRoot.path);
  }
  XcodeProjectInfo? _projectInfo;

  Future<Map<String, String>?> _xcodeProjectBuildSettings(XcodeProjectBuildContext buildContext) async {
    final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter;
    if (xcodeProjectInterpreter == null || !xcodeProjectInterpreter.isInstalled) {
      return null;
    }

    final Map<String, String> buildSettings = await xcodeProjectInterpreter.getBuildSettings(
      xcodeProject.path,
      buildContext: buildContext,
    );
    if (buildSettings != null && buildSettings.isNotEmpty) {
      // No timeouts, flakes, or errors.
      return buildSettings;
    }
    return null;
  }

  Future<void> ensureReadyForPlatformSpecificTooling() async {
    await _regenerateFromTemplateIfNeeded();
    if (!_flutterLibRoot.existsSync()) {
      return;
    }
    await _updateGeneratedXcodeConfigIfNeeded();
  }

  /// Check if one the [targets] of the project is a watchOS companion app target.
329
  Future<bool> containsWatchCompanion(List<String> targets, BuildInfo buildInfo, String? deviceId) async {
330 331 332 333 334 335 336 337 338 339 340
    final String? bundleIdentifier = await productBundleIdentifier(buildInfo);
    // A bundle identifier is required for a companion app.
    if (bundleIdentifier == null) {
      return false;
    }
    for (final String target in targets) {
      // Create Info.plist file of the target.
      final File infoFile = hostAppRoot.childDirectory(target).childFile('Info.plist');
      // The Info.plist file of a target contains the key WKCompanionAppBundleIdentifier,
      // if it is a watchOS companion app.
      if (infoFile.existsSync()) {
341
        final String? fromPlist = globals.plistParser.getStringValueFromFile(infoFile.path, 'WKCompanionAppBundleIdentifier');
342 343 344 345 346 347 348
        if (bundleIdentifier == fromPlist) {
          return true;
        }

        // The key WKCompanionAppBundleIdentifier might contain an xcode variable
        // that needs to be substituted before comparing it with bundle id
        if (fromPlist != null && fromPlist.contains(r'$')) {
349
          final Map<String, String>? allBuildSettings = await buildSettingsForBuildInfo(buildInfo, deviceId: deviceId);
350
          if (allBuildSettings != null) {
351 352
            final String substitutedVariable = substituteXcodeVariables(fromPlist, allBuildSettings);
            if (substitutedVariable == bundleIdentifier) {
353 354 355 356 357 358 359 360 361 362 363 364 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 394 395 396 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 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
              return true;
            }
          }
        }
      }
    }
    return false;
  }

  Future<void> _updateGeneratedXcodeConfigIfNeeded() async {
    if (globals.cache.isOlderThanToolsStamp(generatedXcodePropertiesFile)) {
      await xcode.updateGeneratedXcodeProperties(
        project: parent,
        buildInfo: BuildInfo.debug,
        targetOverride: bundle.defaultMainPath,
      );
    }
  }

  Future<void> _regenerateFromTemplateIfNeeded() async {
    if (!isModule) {
      return;
    }
    final bool pubspecChanged = globals.fsUtils.isOlderThanReference(
      entity: ephemeralModuleDirectory,
      referenceFile: parent.pubspecFile,
    );
    final bool toolingChanged = globals.cache.isOlderThanToolsStamp(ephemeralModuleDirectory);
    if (!pubspecChanged && !toolingChanged) {
      return;
    }

    ErrorHandlingFileSystem.deleteIfExists(ephemeralModuleDirectory, recursive: true);
    await _overwriteFromTemplate(
      globals.fs.path.join('module', 'ios', 'library'),
      ephemeralModuleDirectory,
    );
    // Add ephemeral host app, if a editable host app does not already exist.
    if (!_editableDirectory.existsSync()) {
      await _overwriteFromTemplate(
        globals.fs.path.join('module', 'ios', 'host_app_ephemeral'),
        ephemeralModuleDirectory,
      );
      if (hasPlugins(parent)) {
        await _overwriteFromTemplate(
          globals.fs.path.join('module', 'ios', 'host_app_ephemeral_cocoapods'),
          ephemeralModuleDirectory,
        );
      }
      // Use release mode so host project can link on bitcode variant.
      _copyEngineArtifactToProject(BuildMode.release, EnvironmentType.physical);
    }
  }

  void _copyEngineArtifactToProject(BuildMode mode, EnvironmentType environmentType) {
    // Copy framework from engine cache. The actual build mode
    // doesn't actually matter as it will be overwritten by xcode_backend.sh.
    // However, cocoapods will run before that script and requires something
    // to be in this location.
    final Directory framework = globals.fs.directory(
      globals.artifacts?.getArtifactPath(
        Artifact.flutterXcframework,
        platform: TargetPlatform.ios,
        mode: mode,
        environmentType: environmentType,
      )
    );
    if (framework.existsSync()) {
      copyDirectory(
        framework,
        engineCopyDirectory.childDirectory('Flutter.xcframework'),
      );
    }
  }

  @override
  File get generatedXcodePropertiesFile => _flutterLibRoot
    .childDirectory('Flutter')
    .childFile('Generated.xcconfig');

  /// No longer compiled to this location.
  ///
  /// Used only for "flutter clean" to remove old references.
  Directory get deprecatedCompiledDartFramework => _flutterLibRoot
      .childDirectory('Flutter')
      .childDirectory('App.framework');

  /// No longer copied to this location.
  ///
  /// Used only for "flutter clean" to remove old references.
  Directory get deprecatedProjectFlutterFramework => _flutterLibRoot
      .childDirectory('Flutter')
      .childDirectory('Flutter.framework');

  /// Used only for "flutter clean" to remove old references.
  File get flutterPodspec => _flutterLibRoot
      .childDirectory('Flutter')
      .childFile('Flutter.podspec');

  Directory get pluginRegistrantHost {
    return isModule
        ? _flutterLibRoot
            .childDirectory('Flutter')
            .childDirectory('FlutterPluginRegistrant')
457
        : hostAppRoot.childDirectory(XcodeBasedProject._hostAppProjectName);
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484
  }

  File get pluginRegistrantHeader {
    final Directory registryDirectory = isModule ? pluginRegistrantHost.childDirectory('Classes') : pluginRegistrantHost;
    return registryDirectory.childFile('GeneratedPluginRegistrant.h');
  }

  File get pluginRegistrantImplementation {
    final Directory registryDirectory = isModule ? pluginRegistrantHost.childDirectory('Classes') : pluginRegistrantHost;
    return registryDirectory.childFile('GeneratedPluginRegistrant.m');
  }

  Directory get engineCopyDirectory {
    return isModule
        ? ephemeralModuleDirectory.childDirectory('Flutter').childDirectory('engine')
        : hostAppRoot.childDirectory('Flutter');
  }

  Future<void> _overwriteFromTemplate(String path, Directory target) async {
    final Template template = await Template.fromName(
      path,
      fileSystem: globals.fs,
      templateManifest: null,
      logger: globals.logger,
      templateRenderer: globals.templateRenderer,
    );
    final String iosBundleIdentifier = parent.manifest.iosBundleIdentifier ?? 'com.example.${parent.manifest.appName}';
485 486 487 488 489 490 491 492 493

    final String? iosDevelopmentTeam = await getCodeSigningIdentityDevelopmentTeam(
      processManager: globals.processManager,
      platform: globals.platform,
      logger: globals.logger,
      config: globals.config,
      terminal: globals.terminal,
    );

494 495 496 497 498
    final String projectName = parent.manifest.appName;

    // The dart project_name is in snake_case, this variable is the Title Case of the Project Name.
    final String titleCaseProjectName = snakeCaseToTitleCase(projectName);

499 500 501 502
    template.render(
      target,
      <String, Object>{
        'ios': true,
503 504
        'projectName': projectName,
        'titleCaseProjectName': titleCaseProjectName,
505
        'iosIdentifier': iosBundleIdentifier,
506 507
        'hasIosDevelopmentTeam': iosDevelopmentTeam != null && iosDevelopmentTeam.isNotEmpty,
        'iosDevelopmentTeam': iosDevelopmentTeam ?? '',
508 509 510 511 512 513 514
      },
      printStatusWhenWriting: false,
    );
  }
}

/// The macOS sub project.
515
class MacOSProject extends XcodeBasedProject {
516 517 518 519 520 521 522 523 524
  MacOSProject.fromFlutter(this.parent);

  @override
  final FlutterProject parent;

  @override
  String get pluginConfigKey => MacOSPlugin.kConfigKey;

  @override
525
  bool existsSync() => hostAppRoot.existsSync();
526

527 528
  @override
  Directory get hostAppRoot => parent.directory.childDirectory('macos');
529 530 531 532

  /// The directory in the project that is managed by Flutter. As much as
  /// possible, files that are edited by Flutter tooling after initial project
  /// creation should live here.
533
  Directory get managedDirectory => hostAppRoot.childDirectory('Flutter');
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577

  /// The subdirectory of [managedDirectory] that contains files that are
  /// generated on the fly. All generated files that are not intended to be
  /// checked in should live here.
  Directory get ephemeralDirectory => managedDirectory.childDirectory('ephemeral');

  /// The xcfilelist used to track the inputs for the Flutter script phase in
  /// the Xcode build.
  File get inputFileList => ephemeralDirectory.childFile('FlutterInputs.xcfilelist');

  /// The xcfilelist used to track the outputs for the Flutter script phase in
  /// the Xcode build.
  File get outputFileList => ephemeralDirectory.childFile('FlutterOutputs.xcfilelist');

  @override
  File get generatedXcodePropertiesFile => ephemeralDirectory.childFile('Flutter-Generated.xcconfig');

  @override
  File xcodeConfigFor(String mode) => managedDirectory.childFile('Flutter-$mode.xcconfig');

  @override
  File get generatedEnvironmentVariableExportScript => ephemeralDirectory.childFile('flutter_export_environment.sh');

  /// The file where the Xcode build will write the name of the built app.
  ///
  /// Ideally this will be replaced in the future with inspection of the Runner
  /// scheme's target.
  File get nameFile => ephemeralDirectory.childFile('.app_filename');

  Future<void> ensureReadyForPlatformSpecificTooling() async {
    // TODO(stuartmorgan): Add create-from-template logic here.
    await _updateGeneratedXcodeConfigIfNeeded();
  }

  Future<void> _updateGeneratedXcodeConfigIfNeeded() async {
    if (globals.cache.isOlderThanToolsStamp(generatedXcodePropertiesFile)) {
      await xcode.updateGeneratedXcodeProperties(
        project: parent,
        buildInfo: BuildInfo.debug,
        useMacOSConfig: true,
      );
    }
  }
}