| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | /// @docImport 'ios/mac.dart'; |
| 6 | library; |
| 7 | |
| 8 | import 'base/error_handling_io.dart'; |
| 9 | import 'base/file_system.dart'; |
| 10 | import 'base/logger.dart'; |
| 11 | import 'base/template.dart'; |
| 12 | import 'base/utils.dart'; |
| 13 | import 'base/version.dart'; |
| 14 | import 'build_info.dart'; |
| 15 | import 'build_system/build_system.dart'; |
| 16 | import 'bundle.dart' as bundle; |
| 17 | import 'convert.dart'; |
| 18 | import 'features.dart'; |
| 19 | import 'flutter_plugins.dart'; |
| 20 | import 'globals.dart' as globals; |
| 21 | import 'ios/code_signing.dart'; |
| 22 | import 'ios/plist_parser.dart'; |
| 23 | import 'ios/xcode_build_settings.dart' as xcode; |
| 24 | import 'ios/xcodeproj.dart'; |
| 25 | import 'macos/swift_package_manager.dart'; |
| 26 | import 'macos/xcode.dart'; |
| 27 | import 'platform_plugins.dart'; |
| 28 | import 'project.dart'; |
| 29 | import 'template.dart'; |
| 30 | |
| 31 | /// Represents an Xcode-based sub-project. |
| 32 | /// |
| 33 | /// This defines interfaces common to iOS and macOS projects. |
| 34 | abstract class XcodeBasedProject extends FlutterProjectPlatform { |
| 35 | static const _defaultHostAppName = 'Runner' ; |
| 36 | |
| 37 | /// The Xcode workspace (.xcworkspace directory) of the host app. |
| 38 | Directory? get xcodeWorkspace { |
| 39 | if (!hostAppRoot.existsSync()) { |
| 40 | return null; |
| 41 | } |
| 42 | return _xcodeDirectoryWithExtension('.xcworkspace' ); |
| 43 | } |
| 44 | |
| 45 | /// The project name (.xcodeproj basename) of the host app. |
| 46 | late final String hostAppProjectName = () { |
| 47 | if (!hostAppRoot.existsSync()) { |
| 48 | return _defaultHostAppName; |
| 49 | } |
| 50 | final Directory? xcodeProjectDirectory = _xcodeDirectoryWithExtension('.xcodeproj' ); |
| 51 | return xcodeProjectDirectory != null |
| 52 | ? xcodeProjectDirectory.fileSystem.path.basenameWithoutExtension(xcodeProjectDirectory.path) |
| 53 | : _defaultHostAppName; |
| 54 | }(); |
| 55 | |
| 56 | Directory? _xcodeDirectoryWithExtension(String extension) { |
| 57 | final List<FileSystemEntity> contents = hostAppRoot.listSync(); |
| 58 | for (final entity in contents) { |
| 59 | if (globals.fs.path.extension(entity.path) == extension && |
| 60 | !globals.fs.path.basename(entity.path).startsWith('.' )) { |
| 61 | return hostAppRoot.childDirectory(entity.basename); |
| 62 | } |
| 63 | } |
| 64 | return null; |
| 65 | } |
| 66 | |
| 67 | /// The parent of this project. |
| 68 | FlutterProject get parent; |
| 69 | |
| 70 | Directory get hostAppRoot; |
| 71 | |
| 72 | /// The default 'Info.plist' file of the host app. The developer can change this location in Xcode. |
| 73 | File get defaultHostInfoPlist => |
| 74 | hostAppRoot.childDirectory(_defaultHostAppName).childFile('Info.plist' ); |
| 75 | |
| 76 | /// The Xcode project (.xcodeproj directory) of the host app. |
| 77 | Directory get xcodeProject => hostAppRoot.childDirectory(' $hostAppProjectName.xcodeproj' ); |
| 78 | |
| 79 | /// The 'project.pbxproj' file of [xcodeProject]. |
| 80 | File get xcodeProjectInfoFile => xcodeProject.childFile('project.pbxproj' ); |
| 81 | |
| 82 | /// The 'Runner.xcscheme' file of [xcodeProject]. |
| 83 | File xcodeProjectSchemeFile({String? scheme}) { |
| 84 | final String schemeName = scheme ?? 'Runner' ; |
| 85 | return xcodeProject |
| 86 | .childDirectory('xcshareddata' ) |
| 87 | .childDirectory('xcschemes' ) |
| 88 | .childFile(' $schemeName.xcscheme' ); |
| 89 | } |
| 90 | |
| 91 | File get xcodeProjectWorkspaceData => |
| 92 | xcodeProject.childDirectory('project.xcworkspace' ).childFile('contents.xcworkspacedata' ); |
| 93 | |
| 94 | /// Xcode workspace shared data directory for the host app. |
| 95 | Directory? get xcodeWorkspaceSharedData => xcodeWorkspace?.childDirectory('xcshareddata' ); |
| 96 | |
| 97 | /// Xcode workspace shared workspace settings file for the host app. |
| 98 | File? get xcodeWorkspaceSharedSettings => |
| 99 | xcodeWorkspaceSharedData?.childFile('WorkspaceSettings.xcsettings' ); |
| 100 | |
| 101 | /// Contains definitions for FLUTTER_ROOT, LOCAL_ENGINE, and more flags for |
| 102 | /// the Xcode build. |
| 103 | File get generatedXcodePropertiesFile; |
| 104 | |
| 105 | /// The Flutter-managed Xcode config file for [mode]. |
| 106 | File xcodeConfigFor(String mode); |
| 107 | |
| 108 | /// The script that exports environment variables needed for Flutter tools. |
| 109 | /// Can be run first in a Xcode Script build phase to make FLUTTER_ROOT, |
| 110 | /// LOCAL_ENGINE, and other Flutter variables available to any flutter |
| 111 | /// tooling (`flutter build`, etc) to convert into flags. |
| 112 | File get generatedEnvironmentVariableExportScript; |
| 113 | |
| 114 | /// The CocoaPods 'Podfile'. |
| 115 | File get podfile => hostAppRoot.childFile('Podfile' ); |
| 116 | |
| 117 | /// The CocoaPods 'Podfile.lock'. |
| 118 | File get podfileLock => hostAppRoot.childFile('Podfile.lock' ); |
| 119 | |
| 120 | /// The CocoaPods 'Manifest.lock'. |
| 121 | File get podManifestLock => hostAppRoot.childDirectory('Pods' ).childFile('Manifest.lock' ); |
| 122 | |
| 123 | /// The CocoaPods generated 'Pods-Runner-frameworks.sh'. |
| 124 | File get podRunnerFrameworksScript => |
| 125 | podRunnerTargetSupportFiles.childFile('Pods-Runner-frameworks.sh' ); |
| 126 | |
| 127 | /// The CocoaPods generated directory 'Pods-Runner'. |
| 128 | Directory get podRunnerTargetSupportFiles => hostAppRoot |
| 129 | .childDirectory('Pods' ) |
| 130 | .childDirectory('Target Support Files' ) |
| 131 | .childDirectory('Pods-Runner' ); |
| 132 | |
| 133 | /// The directory in the project that is managed by Flutter. As much as |
| 134 | /// possible, files that are edited by Flutter tooling after initial project |
| 135 | /// creation should live here. |
| 136 | Directory get managedDirectory => hostAppRoot.childDirectory('Flutter' ); |
| 137 | |
| 138 | /// The subdirectory of [managedDirectory] that contains files that are |
| 139 | /// generated on the fly. All generated files that are not intended to be |
| 140 | /// checked in should live here. |
| 141 | Directory get ephemeralDirectory => managedDirectory.childDirectory('ephemeral' ); |
| 142 | |
| 143 | /// The Flutter generated directory for generated Swift packages. |
| 144 | Directory get flutterSwiftPackagesDirectory => ephemeralDirectory.childDirectory('Packages' ); |
| 145 | |
| 146 | /// Flutter plugins that support SwiftPM will be symlinked in this directory to keep all |
| 147 | /// Swift packages relative to each other. |
| 148 | Directory get relativeSwiftPackagesDirectory => |
| 149 | flutterSwiftPackagesDirectory.childDirectory('.packages' ); |
| 150 | |
| 151 | /// The Flutter generated directory for the Swift package handling plugin |
| 152 | /// dependencies. |
| 153 | Directory get flutterPluginSwiftPackageDirectory => |
| 154 | flutterSwiftPackagesDirectory.childDirectory(kFlutterGeneratedPluginSwiftPackageName); |
| 155 | |
| 156 | /// The Flutter generated Swift package manifest (Package.swift) for plugin |
| 157 | /// dependencies. |
| 158 | File get flutterPluginSwiftPackageManifest => |
| 159 | flutterPluginSwiftPackageDirectory.childFile('Package.swift' ); |
| 160 | |
| 161 | /// Checks if FlutterGeneratedPluginSwiftPackage has been added to the |
| 162 | /// project's build settings by checking the contents of the pbxproj. |
| 163 | bool get flutterPluginSwiftPackageInProjectSettings { |
| 164 | return xcodeProjectInfoFile.existsSync() && |
| 165 | xcodeProjectInfoFile.readAsStringSync().contains(kFlutterGeneratedPluginSwiftPackageName); |
| 166 | } |
| 167 | |
| 168 | /// True if this project doesn't have Swift Package Manager disabled in the |
| 169 | /// pubspec, has either an iOS or macOS platform implementation, is not a |
| 170 | /// module project, Xcode is 15 or greater, and the Swift Package Manager |
| 171 | /// feature is enabled. |
| 172 | bool get usesSwiftPackageManager { |
| 173 | if (!featureFlags.isSwiftPackageManagerEnabled) { |
| 174 | return false; |
| 175 | } |
| 176 | |
| 177 | // TODO(loic-sharma): Support Swift Package Manager in add-to-app modules. |
| 178 | // https://github.com/flutter/flutter/issues/146957 |
| 179 | if (parent.isModule) { |
| 180 | return false; |
| 181 | } |
| 182 | |
| 183 | if (!existsSync()) { |
| 184 | return false; |
| 185 | } |
| 186 | |
| 187 | // Swift Package Manager requires Xcode 15 or greater. |
| 188 | final Xcode? xcode = globals.xcode; |
| 189 | final Version? xcodeVersion = xcode?.currentVersion; |
| 190 | if (xcodeVersion == null || xcodeVersion.major < 15) { |
| 191 | return false; |
| 192 | } |
| 193 | |
| 194 | return true; |
| 195 | } |
| 196 | |
| 197 | Future<XcodeProjectInfo?> projectInfo() async { |
| 198 | final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; |
| 199 | if (!xcodeProject.existsSync() || |
| 200 | xcodeProjectInterpreter == null || |
| 201 | !xcodeProjectInterpreter.isInstalled) { |
| 202 | return null; |
| 203 | } |
| 204 | return _projectInfo ??= await xcodeProjectInterpreter.getInfo(hostAppRoot.path); |
| 205 | } |
| 206 | |
| 207 | XcodeProjectInfo? _projectInfo; |
| 208 | |
| 209 | /// Get the scheme using the Xcode's project [XcodeProjectInfo.schemes] and |
| 210 | /// the [BuildInfo.flavor]. |
| 211 | Future<String?> schemeForBuildInfo(BuildInfo buildInfo, {Logger? logger}) async { |
| 212 | final XcodeProjectInfo? info = await projectInfo(); |
| 213 | if (info == null) { |
| 214 | logger?.printError('Xcode project info not found.' ); |
| 215 | return null; |
| 216 | } |
| 217 | |
| 218 | final String? scheme = info.schemeFor(buildInfo); |
| 219 | if (scheme == null) { |
| 220 | info.reportFlavorNotFoundAndExit(); |
| 221 | } |
| 222 | return scheme; |
| 223 | } |
| 224 | |
| 225 | /// The build settings for the host app of this project, as a detached map. |
| 226 | /// |
| 227 | /// Returns null, if Xcode tooling is unavailable. |
| 228 | Future<Map<String, String>?> buildSettingsForBuildInfo( |
| 229 | BuildInfo? buildInfo, { |
| 230 | String? scheme, |
| 231 | String? configuration, |
| 232 | String? target, |
| 233 | EnvironmentType environmentType = EnvironmentType.physical, |
| 234 | String? deviceId, |
| 235 | bool isWatch = false, |
| 236 | }) async { |
| 237 | if (!existsSync()) { |
| 238 | return null; |
| 239 | } |
| 240 | final XcodeProjectInfo? info = await projectInfo(); |
| 241 | if (info == null) { |
| 242 | return null; |
| 243 | } |
| 244 | |
| 245 | scheme ??= info.schemeFor(buildInfo); |
| 246 | if (scheme == null) { |
| 247 | info.reportFlavorNotFoundAndExit(); |
| 248 | } |
| 249 | |
| 250 | configuration ??= (await projectInfo())?.buildConfigurationFor(buildInfo, scheme); |
| 251 | |
| 252 | final XcodeSdk sdk = switch ((environmentType, this)) { |
| 253 | (EnvironmentType.physical, _) when isWatch => XcodeSdk.WatchOS, |
| 254 | (EnvironmentType.simulator, _) when isWatch => XcodeSdk.WatchSimulator, |
| 255 | (EnvironmentType.physical, IosProject _) => XcodeSdk.IPhoneOS, |
| 256 | (EnvironmentType.simulator, IosProject _) => XcodeSdk.IPhoneSimulator, |
| 257 | (EnvironmentType.physical, MacOSProject _) => XcodeSdk.MacOSX, |
| 258 | (_, _) => throw ArgumentError('Unsupported SDK' ), |
| 259 | }; |
| 260 | |
| 261 | return _buildSettingsForXcodeProjectBuildContext( |
| 262 | XcodeProjectBuildContext( |
| 263 | scheme: scheme, |
| 264 | configuration: configuration, |
| 265 | sdk: sdk, |
| 266 | target: target, |
| 267 | deviceId: deviceId, |
| 268 | ), |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | Future<Map<String, String>?> _buildSettingsForXcodeProjectBuildContext( |
| 273 | XcodeProjectBuildContext buildContext, |
| 274 | ) async { |
| 275 | if (!existsSync()) { |
| 276 | return null; |
| 277 | } |
| 278 | final Map<String, String>? currentBuildSettings = _buildSettingsByBuildContext[buildContext]; |
| 279 | if (currentBuildSettings == null) { |
| 280 | final Map<String, String>? calculatedBuildSettings = await _xcodeProjectBuildSettings( |
| 281 | buildContext, |
| 282 | ); |
| 283 | if (calculatedBuildSettings != null) { |
| 284 | _buildSettingsByBuildContext[buildContext] = calculatedBuildSettings; |
| 285 | } |
| 286 | } |
| 287 | return _buildSettingsByBuildContext[buildContext]; |
| 288 | } |
| 289 | |
| 290 | final _buildSettingsByBuildContext = <XcodeProjectBuildContext, Map<String, String>>{}; |
| 291 | |
| 292 | Future<Map<String, String>?> _xcodeProjectBuildSettings( |
| 293 | XcodeProjectBuildContext buildContext, |
| 294 | ) async { |
| 295 | final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; |
| 296 | if (xcodeProjectInterpreter == null || !xcodeProjectInterpreter.isInstalled) { |
| 297 | return null; |
| 298 | } |
| 299 | |
| 300 | final Map<String, String> buildSettings = await xcodeProjectInterpreter.getBuildSettings( |
| 301 | xcodeProject.path, |
| 302 | buildContext: buildContext, |
| 303 | ); |
| 304 | if (buildSettings.isNotEmpty) { |
| 305 | // No timeouts, flakes, or errors. |
| 306 | return buildSettings; |
| 307 | } |
| 308 | return null; |
| 309 | } |
| 310 | |
| 311 | /// When flutter assemble runs within an Xcode run script, it does not know |
| 312 | /// the scheme and therefore doesn't know what flavor is being used. This |
| 313 | /// makes a best effort to parse the scheme name from the [kXcodeConfiguration]. |
| 314 | /// Most flavor's [kXcodeConfiguration] should follow the naming convention |
| 315 | /// of '$baseConfiguration-$scheme'. This is only semi-enforced by |
| 316 | /// [buildXcodeProject], so it may not work. Also check if separated by a |
| 317 | /// space instead of a `-`. Once parsed, match it with a scheme/flavor name. |
| 318 | /// If the flavor cannot be parsed or matched, use the [kFlavor] environment |
| 319 | /// variable, which may or may not be set/correct, as a fallback. |
| 320 | Future<String?> parseFlavorFromConfiguration(Environment environment) async { |
| 321 | final String? configuration = environment.defines[kXcodeConfiguration]; |
| 322 | final String? flavor = environment.defines[kFlavor]; |
| 323 | if (configuration == null) { |
| 324 | return flavor; |
| 325 | } |
| 326 | List<String> splitConfiguration = configuration.split('-' ); |
| 327 | if (splitConfiguration.length == 1) { |
| 328 | splitConfiguration = configuration.split(' ' ); |
| 329 | } |
| 330 | if (splitConfiguration.length == 1) { |
| 331 | return flavor; |
| 332 | } |
| 333 | final String parsedScheme = splitConfiguration[1]; |
| 334 | |
| 335 | final XcodeProjectInfo? info = await projectInfo(); |
| 336 | if (info == null) { |
| 337 | return flavor; |
| 338 | } |
| 339 | for (final String schemeName in info.schemes) { |
| 340 | if (schemeName.toLowerCase() == parsedScheme.toLowerCase()) { |
| 341 | return schemeName; |
| 342 | } |
| 343 | } |
| 344 | return flavor; |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | /// Represents the iOS sub-project of a Flutter project. |
| 349 | /// |
| 350 | /// Instances will reflect the contents of the `ios/` sub-folder of |
| 351 | /// Flutter applications and the `.ios/` sub-folder of Flutter module projects. |
| 352 | class IosProject extends XcodeBasedProject { |
| 353 | IosProject.fromFlutter(this.parent); |
| 354 | |
| 355 | @override |
| 356 | final FlutterProject parent; |
| 357 | |
| 358 | @override |
| 359 | String get pluginConfigKey => IOSPlugin.kConfigKey; |
| 360 | |
| 361 | // build setting keys |
| 362 | static const kProductBundleIdKey = 'PRODUCT_BUNDLE_IDENTIFIER' ; |
| 363 | static const kTeamIdKey = 'DEVELOPMENT_TEAM' ; |
| 364 | static const kEntitlementFilePathKey = 'CODE_SIGN_ENTITLEMENTS' ; |
| 365 | static const kProductNameKey = 'PRODUCT_NAME' ; |
| 366 | |
| 367 | static final _productBundleIdPattern = RegExp( |
| 368 | '^\\s* $kProductBundleIdKey\\s*=\\s*(["\']?)(.*?)\\1;\\s*\$' , |
| 369 | ); |
| 370 | static const _kProductBundleIdVariable = '\$( $kProductBundleIdKey)' ; |
| 371 | |
| 372 | // The string starts with `applinks:` and ignores the query param which starts with `?`. |
| 373 | static final _associatedDomainPattern = RegExp(r'^applinks:([^?]+)' ); |
| 374 | |
| 375 | static const _lldbPythonHelperTemplateName = 'flutter_lldb_helper.py' ; |
| 376 | |
| 377 | static const _lldbInitTemplate = |
| 378 | ''' |
| 379 | # |
| 380 | # Generated file, do not edit. |
| 381 | # |
| 382 | |
| 383 | command script import --relative-to-command-file $_lldbPythonHelperTemplateName |
| 384 | ''' ; |
| 385 | |
| 386 | static const _lldbPythonHelperTemplate = r''' |
| 387 | # |
| 388 | # Generated file, do not edit. |
| 389 | # |
| 390 | |
| 391 | import lldb |
| 392 | |
| 393 | def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): |
| 394 | """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" |
| 395 | base = frame.register["x0"].GetValueAsAddress() |
| 396 | page_len = frame.register["x1"].GetValueAsUnsigned() |
| 397 | |
| 398 | # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the |
| 399 | # first page to see if handled it correctly. This makes diagnosing |
| 400 | # misconfiguration (e.g. missing breakpoint) easier. |
| 401 | data = bytearray(page_len) |
| 402 | data[0:8] = b'IHELPED!' |
| 403 | |
| 404 | error = lldb.SBError() |
| 405 | frame.GetThread().GetProcess().WriteMemory(base, data, error) |
| 406 | if not error.Success(): |
| 407 | print(f'Failed to write into {base}[+{page_len}]', error) |
| 408 | return |
| 409 | |
| 410 | def __lldb_init_module(debugger: lldb.SBDebugger, _): |
| 411 | target = debugger.GetDummyTarget() |
| 412 | # Caveat: must use BreakpointCreateByRegEx here and not |
| 413 | # BreakpointCreateByName. For some reasons callback function does not |
| 414 | # get carried over from dummy target for the later. |
| 415 | bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") |
| 416 | bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) |
| 417 | bp.SetAutoContinue(True) |
| 418 | print("-- LLDB integration loaded --") |
| 419 | ''' ; |
| 420 | |
| 421 | Directory get ephemeralModuleDirectory => parent.directory.childDirectory('.ios' ); |
| 422 | Directory get _editableDirectory => parent.directory.childDirectory('ios' ); |
| 423 | |
| 424 | /// This parent folder of `Runner.xcodeproj`. |
| 425 | @override |
| 426 | Directory get hostAppRoot { |
| 427 | if (!isModule || _editableDirectory.existsSync()) { |
| 428 | return _editableDirectory; |
| 429 | } |
| 430 | return ephemeralModuleDirectory; |
| 431 | } |
| 432 | |
| 433 | /// The root directory of the iOS wrapping of Flutter and plugins. This is the |
| 434 | /// parent of the `Flutter/` folder into which Flutter artifacts are written |
| 435 | /// during build. |
| 436 | /// |
| 437 | /// This is the same as [hostAppRoot] except when the project is |
| 438 | /// a Flutter module with an editable host app. |
| 439 | Directory get _flutterLibRoot => isModule ? ephemeralModuleDirectory : _editableDirectory; |
| 440 | |
| 441 | /// True, if the parent Flutter project is a module project. |
| 442 | bool get isModule => parent.isModule; |
| 443 | |
| 444 | /// Whether the Flutter application has an iOS project. |
| 445 | bool get exists => hostAppRoot.existsSync(); |
| 446 | |
| 447 | @override |
| 448 | Directory get managedDirectory => _flutterLibRoot.childDirectory('Flutter' ); |
| 449 | |
| 450 | @override |
| 451 | File xcodeConfigFor(String mode) => managedDirectory.childFile(' $mode.xcconfig' ); |
| 452 | |
| 453 | @override |
| 454 | File get generatedEnvironmentVariableExportScript => |
| 455 | managedDirectory.childFile('flutter_export_environment.sh' ); |
| 456 | |
| 457 | File get appFrameworkInfoPlist => managedDirectory.childFile('AppFrameworkInfo.plist' ); |
| 458 | |
| 459 | /// The 'AppDelegate.swift' file of the host app. This file might not exist if the app project uses Objective-C. |
| 460 | File get appDelegateSwift => |
| 461 | _editableDirectory.childDirectory('Runner' ).childFile('AppDelegate.swift' ); |
| 462 | |
| 463 | /// The 'AppDelegate.m' file of the host app. This file might not exist if the app project uses Swift. |
| 464 | File get appDelegateObjc => |
| 465 | _editableDirectory.childDirectory('Runner' ).childFile('AppDelegate.m' ); |
| 466 | |
| 467 | File get infoPlist => _editableDirectory.childDirectory('Runner' ).childFile('Info.plist' ); |
| 468 | |
| 469 | Directory get symlinks => _flutterLibRoot.childDirectory('.symlinks' ); |
| 470 | |
| 471 | /// True if the app project uses Swift. |
| 472 | bool get isSwift => appDelegateSwift.existsSync(); |
| 473 | |
| 474 | /// Do all plugins support arm64 simulators to run natively on an ARM Mac? |
| 475 | Future<bool> pluginsSupportArmSimulator() async { |
| 476 | final Directory podXcodeProject = hostAppRoot |
| 477 | .childDirectory('Pods' ) |
| 478 | .childDirectory('Pods.xcodeproj' ); |
| 479 | if (!podXcodeProject.existsSync()) { |
| 480 | // No plugins. |
| 481 | return true; |
| 482 | } |
| 483 | |
| 484 | final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; |
| 485 | if (xcodeProjectInterpreter == null) { |
| 486 | // Xcode isn't installed, don't try to check. |
| 487 | return false; |
| 488 | } |
| 489 | final String? buildSettings = await xcodeProjectInterpreter.pluginsBuildSettingsOutput( |
| 490 | podXcodeProject, |
| 491 | ); |
| 492 | |
| 493 | // See if any plugins or their dependencies exclude arm64 simulators |
| 494 | // as a valid architecture, usually because a binary is missing that slice. |
| 495 | // Example: EXCLUDED_ARCHS = arm64 i386 |
| 496 | // NOT: EXCLUDED_ARCHS = i386 |
| 497 | return buildSettings != null && !buildSettings.contains(RegExp('EXCLUDED_ARCHS.*arm64' )); |
| 498 | } |
| 499 | |
| 500 | @override |
| 501 | bool existsSync() { |
| 502 | return parent.isModule || _editableDirectory.existsSync(); |
| 503 | } |
| 504 | |
| 505 | /// Outputs universal link related project settings of the iOS sub-project into |
| 506 | /// a json file. |
| 507 | /// |
| 508 | /// The return future will resolve to string path to the output file. |
| 509 | Future<String> outputsUniversalLinkSettings({ |
| 510 | required String configuration, |
| 511 | required String target, |
| 512 | }) async { |
| 513 | final context = XcodeProjectBuildContext(configuration: configuration, target: target); |
| 514 | final File file = await parent.buildDirectory |
| 515 | .childDirectory('deeplink_data' ) |
| 516 | .childFile('universal-link-settings- $configuration- $target.json' ) |
| 517 | .create(recursive: true); |
| 518 | |
| 519 | await file.writeAsString( |
| 520 | jsonEncode(<String, Object?>{ |
| 521 | 'bundleIdentifier' : await _productBundleIdentifierWithBuildContext(context), |
| 522 | 'teamIdentifier' : await _getTeamIdentifier(context), |
| 523 | 'associatedDomains' : await _getAssociatedDomains(context), |
| 524 | }), |
| 525 | ); |
| 526 | return file.absolute.path; |
| 527 | } |
| 528 | |
| 529 | /// The product bundle identifier of the host app, or null if not set or if |
| 530 | /// iOS tooling needed to read it is not installed. |
| 531 | Future<String?> productBundleIdentifier(BuildInfo? buildInfo) async { |
| 532 | if (!existsSync()) { |
| 533 | return null; |
| 534 | } |
| 535 | |
| 536 | XcodeProjectBuildContext? buildContext; |
| 537 | final XcodeProjectInfo? info = await projectInfo(); |
| 538 | if (info != null) { |
| 539 | final String? scheme = info.schemeFor(buildInfo); |
| 540 | if (scheme == null) { |
| 541 | info.reportFlavorNotFoundAndExit(); |
| 542 | } |
| 543 | final String? configuration = info.buildConfigurationFor(buildInfo, scheme); |
| 544 | buildContext = XcodeProjectBuildContext(configuration: configuration, scheme: scheme); |
| 545 | } |
| 546 | return _productBundleIdentifierWithBuildContext(buildContext); |
| 547 | } |
| 548 | |
| 549 | Future<String?> _productBundleIdentifierWithBuildContext( |
| 550 | XcodeProjectBuildContext? buildContext, |
| 551 | ) async { |
| 552 | if (!existsSync()) { |
| 553 | return null; |
| 554 | } |
| 555 | if (_productBundleIdentifiers.containsKey(buildContext)) { |
| 556 | return _productBundleIdentifiers[buildContext]; |
| 557 | } |
| 558 | return _productBundleIdentifiers[buildContext] = await _parseProductBundleIdentifier( |
| 559 | buildContext, |
| 560 | ); |
| 561 | } |
| 562 | |
| 563 | final _productBundleIdentifiers = <XcodeProjectBuildContext?, String?>{}; |
| 564 | |
| 565 | Future<String?> _parseProductBundleIdentifier(XcodeProjectBuildContext? buildContext) async { |
| 566 | String? fromPlist; |
| 567 | final File defaultInfoPlist = defaultHostInfoPlist; |
| 568 | // Users can change the location of the Info.plist. |
| 569 | // Try parsing the default, first. |
| 570 | if (defaultInfoPlist.existsSync()) { |
| 571 | try { |
| 572 | fromPlist = globals.plistParser.getValueFromFile<String>( |
| 573 | defaultHostInfoPlist.path, |
| 574 | PlistParser.kCFBundleIdentifierKey, |
| 575 | ); |
| 576 | } on FileNotFoundException { |
| 577 | // iOS tooling not found; likely not running OSX; let [fromPlist] be null |
| 578 | } |
| 579 | if (fromPlist != null && !fromPlist.contains(r'$' )) { |
| 580 | // Info.plist has no build variables in product bundle ID. |
| 581 | return fromPlist; |
| 582 | } |
| 583 | } |
| 584 | if (buildContext == null) { |
| 585 | // Getting build settings to evaluate info.Plist requires a context. |
| 586 | return null; |
| 587 | } |
| 588 | |
| 589 | final Map<String, String>? allBuildSettings = await _buildSettingsForXcodeProjectBuildContext( |
| 590 | buildContext, |
| 591 | ); |
| 592 | if (allBuildSettings != null) { |
| 593 | if (fromPlist != null) { |
| 594 | // Perform variable substitution using build settings. |
| 595 | return substituteXcodeVariables(fromPlist, allBuildSettings); |
| 596 | } |
| 597 | return allBuildSettings[kProductBundleIdKey]; |
| 598 | } |
| 599 | |
| 600 | // On non-macOS platforms, parse the first PRODUCT_BUNDLE_IDENTIFIER from |
| 601 | // the project file. This can return the wrong bundle identifier if additional |
| 602 | // bundles have been added to the project and are found first, like frameworks |
| 603 | // or companion watchOS projects. However, on non-macOS platforms this is |
| 604 | // only used for display purposes and to regenerate organization names, so |
| 605 | // best-effort is probably fine. |
| 606 | final String? fromPbxproj = firstMatchInFile( |
| 607 | xcodeProjectInfoFile, |
| 608 | _productBundleIdPattern, |
| 609 | )?.group(2); |
| 610 | if (fromPbxproj != null && (fromPlist == null || fromPlist == _kProductBundleIdVariable)) { |
| 611 | return fromPbxproj; |
| 612 | } |
| 613 | return null; |
| 614 | } |
| 615 | |
| 616 | Future<String?> _getTeamIdentifier(XcodeProjectBuildContext buildContext) async { |
| 617 | final Map<String, String>? buildSettings = await _buildSettingsForXcodeProjectBuildContext( |
| 618 | buildContext, |
| 619 | ); |
| 620 | return buildSettings?[kTeamIdKey]; |
| 621 | } |
| 622 | |
| 623 | Future<List<String>> _getAssociatedDomains(XcodeProjectBuildContext buildContext) async { |
| 624 | final Map<String, String>? buildSettings = await _buildSettingsForXcodeProjectBuildContext( |
| 625 | buildContext, |
| 626 | ); |
| 627 | if (buildSettings != null) { |
| 628 | final String? entitlementPath = buildSettings[kEntitlementFilePathKey]; |
| 629 | if (entitlementPath != null) { |
| 630 | final File entitlement = hostAppRoot.childFile(entitlementPath); |
| 631 | if (entitlement.existsSync()) { |
| 632 | final List<String>? domains = globals.plistParser |
| 633 | .getValueFromFile<List<Object>>(entitlement.path, PlistParser.kAssociatedDomainsKey) |
| 634 | ?.cast<String>(); |
| 635 | |
| 636 | if (domains != null) { |
| 637 | return <String>[ |
| 638 | for (final String domain in domains) |
| 639 | if (_associatedDomainPattern.firstMatch(domain) case final RegExpMatch match) |
| 640 | match.group(1)!, |
| 641 | ]; |
| 642 | } |
| 643 | } |
| 644 | } |
| 645 | } |
| 646 | return const <String>[]; |
| 647 | } |
| 648 | |
| 649 | /// The product name of the app, `My App`. |
| 650 | Future<String?> productName(BuildInfo? buildInfo) async { |
| 651 | if (!existsSync()) { |
| 652 | return null; |
| 653 | } |
| 654 | return _productName ??= await _parseProductName(buildInfo); |
| 655 | } |
| 656 | |
| 657 | String? _productName; |
| 658 | |
| 659 | Future<String> _parseProductName(BuildInfo? buildInfo) async { |
| 660 | // The product name and bundle name are derived from the display name, which the user |
| 661 | // is instructed to change in Xcode as part of deploying to the App Store. |
| 662 | // https://flutter.dev/to/xcode-name-config |
| 663 | // The only source of truth for the name is Xcode's interpretation of the build settings. |
| 664 | String? productName; |
| 665 | if (globals.xcodeProjectInterpreter?.isInstalled ?? false) { |
| 666 | final Map<String, String>? xcodeBuildSettings = await buildSettingsForBuildInfo(buildInfo); |
| 667 | if (xcodeBuildSettings != null) { |
| 668 | productName = xcodeBuildSettings[kProductNameKey]; |
| 669 | } |
| 670 | } |
| 671 | if (productName == null) { |
| 672 | globals.printTrace(' $kProductNameKey not present, defaulting to $hostAppProjectName' ); |
| 673 | } |
| 674 | return productName ?? XcodeBasedProject._defaultHostAppName; |
| 675 | } |
| 676 | |
| 677 | Future<void> ensureReadyForPlatformSpecificTooling() async { |
| 678 | await _regenerateModuleFromTemplateIfNeeded(); |
| 679 | await _updateLLDBIfNeeded(); |
| 680 | if (!_flutterLibRoot.existsSync()) { |
| 681 | return; |
| 682 | } |
| 683 | await _updateGeneratedXcodeConfigIfNeeded(); |
| 684 | } |
| 685 | |
| 686 | /// Check if one the [XcodeProjectInfo.targets] of the project is |
| 687 | /// a watchOS companion app target. |
| 688 | Future<bool> containsWatchCompanion({ |
| 689 | required XcodeProjectInfo projectInfo, |
| 690 | required BuildInfo buildInfo, |
| 691 | String? deviceId, |
| 692 | }) async { |
| 693 | final String? bundleIdentifier = await productBundleIdentifier(buildInfo); |
| 694 | // A bundle identifier is required for a companion app. |
| 695 | if (bundleIdentifier == null) { |
| 696 | return false; |
| 697 | } |
| 698 | for (final String target in projectInfo.targets) { |
| 699 | // Create Info.plist file of the target. |
| 700 | final File infoFile = hostAppRoot.childDirectory(target).childFile('Info.plist' ); |
| 701 | // In older versions of Xcode, if the target was a watchOS companion app, |
| 702 | // the Info.plist file of the target contained the key WKCompanionAppBundleIdentifier. |
| 703 | if (infoFile.existsSync()) { |
| 704 | final String? fromPlist = globals.plistParser.getValueFromFile<String>( |
| 705 | infoFile.path, |
| 706 | 'WKCompanionAppBundleIdentifier' , |
| 707 | ); |
| 708 | if (bundleIdentifier == fromPlist) { |
| 709 | return true; |
| 710 | } |
| 711 | |
| 712 | // The key WKCompanionAppBundleIdentifier might contain an xcode variable |
| 713 | // that needs to be substituted before comparing it with bundle id |
| 714 | if (fromPlist != null && fromPlist.contains(r'$' )) { |
| 715 | final Map<String, String>? allBuildSettings = await buildSettingsForBuildInfo( |
| 716 | buildInfo, |
| 717 | deviceId: deviceId, |
| 718 | ); |
| 719 | if (allBuildSettings != null) { |
| 720 | final String substitutedVariable = substituteXcodeVariables( |
| 721 | fromPlist, |
| 722 | allBuildSettings, |
| 723 | ); |
| 724 | if (substitutedVariable == bundleIdentifier) { |
| 725 | return true; |
| 726 | } |
| 727 | } |
| 728 | } |
| 729 | } |
| 730 | } |
| 731 | |
| 732 | // If key not found in Info.plist above, do more expensive check of build settings. |
| 733 | // In newer versions of Xcode, the build settings of the watchOS companion |
| 734 | // app's scheme should contain the key INFOPLIST_KEY_WKCompanionAppBundleIdentifier. |
| 735 | final bool watchIdentifierFound = xcodeProjectInfoFile.readAsStringSync().contains( |
| 736 | 'WKCompanionAppBundleIdentifier' , |
| 737 | ); |
| 738 | if (!watchIdentifierFound) { |
| 739 | return false; |
| 740 | } |
| 741 | |
| 742 | final String? defaultScheme = projectInfo.schemeFor(buildInfo); |
| 743 | if (defaultScheme == null) { |
| 744 | projectInfo.reportFlavorNotFoundAndExit(); |
| 745 | } |
| 746 | for (final String scheme in projectInfo.schemes) { |
| 747 | // the default scheme should not be a watch scheme, so skip it |
| 748 | if (scheme == defaultScheme) { |
| 749 | continue; |
| 750 | } |
| 751 | final Map<String, String>? allBuildSettings = await buildSettingsForBuildInfo( |
| 752 | buildInfo, |
| 753 | deviceId: deviceId, |
| 754 | scheme: scheme, |
| 755 | isWatch: true, |
| 756 | ); |
| 757 | if (allBuildSettings != null) { |
| 758 | final String? fromBuild = allBuildSettings['INFOPLIST_KEY_WKCompanionAppBundleIdentifier' ]; |
| 759 | if (bundleIdentifier == fromBuild) { |
| 760 | return true; |
| 761 | } |
| 762 | if (fromBuild != null && fromBuild.contains(r'$' )) { |
| 763 | final String substitutedVariable = substituteXcodeVariables(fromBuild, allBuildSettings); |
| 764 | if (substitutedVariable == bundleIdentifier) { |
| 765 | return true; |
| 766 | } |
| 767 | } |
| 768 | } |
| 769 | } |
| 770 | return false; |
| 771 | } |
| 772 | |
| 773 | Future<void> _updateGeneratedXcodeConfigIfNeeded() async { |
| 774 | if (globals.cache.isOlderThanToolsStamp(generatedXcodePropertiesFile)) { |
| 775 | await xcode.updateGeneratedXcodeProperties( |
| 776 | project: parent, |
| 777 | buildInfo: BuildInfo.dummy, |
| 778 | targetOverride: bundle.defaultMainPath, |
| 779 | ); |
| 780 | } |
| 781 | } |
| 782 | |
| 783 | Future<void> _updateLLDBIfNeeded() async { |
| 784 | if (globals.cache.isOlderThanToolsStamp(lldbInitFile) || |
| 785 | globals.cache.isOlderThanToolsStamp(lldbHelperPythonFile)) { |
| 786 | await _renderTemplateToFile(_lldbInitTemplate, null, lldbInitFile, globals.templateRenderer); |
| 787 | await _renderTemplateToFile( |
| 788 | _lldbPythonHelperTemplate, |
| 789 | null, |
| 790 | lldbHelperPythonFile, |
| 791 | globals.templateRenderer, |
| 792 | ); |
| 793 | } |
| 794 | } |
| 795 | |
| 796 | Future<void> _renderTemplateToFile( |
| 797 | String template, |
| 798 | Object? context, |
| 799 | File file, |
| 800 | TemplateRenderer templateRenderer, |
| 801 | ) async { |
| 802 | final String renderedTemplate = templateRenderer.renderString(template, context); |
| 803 | await file.create(recursive: true); |
| 804 | await file.writeAsString(renderedTemplate); |
| 805 | } |
| 806 | |
| 807 | Future<void> _regenerateModuleFromTemplateIfNeeded() async { |
| 808 | if (!isModule) { |
| 809 | return; |
| 810 | } |
| 811 | final bool pubspecChanged = globals.fsUtils.isOlderThanReference( |
| 812 | entity: ephemeralModuleDirectory, |
| 813 | referenceFile: parent.pubspecFile, |
| 814 | ); |
| 815 | final bool toolingChanged = globals.cache.isOlderThanToolsStamp(ephemeralModuleDirectory); |
| 816 | if (!pubspecChanged && !toolingChanged) { |
| 817 | return; |
| 818 | } |
| 819 | |
| 820 | ErrorHandlingFileSystem.deleteIfExists(ephemeralModuleDirectory, recursive: true); |
| 821 | await _overwriteFromTemplate( |
| 822 | globals.fs.path.join('module' , 'ios' , 'library' ), |
| 823 | ephemeralModuleDirectory, |
| 824 | ); |
| 825 | // Add ephemeral host app, if a editable host app does not already exist. |
| 826 | if (!_editableDirectory.existsSync()) { |
| 827 | await _overwriteFromTemplate( |
| 828 | globals.fs.path.join('module' , 'ios' , 'host_app_ephemeral' ), |
| 829 | ephemeralModuleDirectory, |
| 830 | ); |
| 831 | if (hasPlugins(parent)) { |
| 832 | await _overwriteFromTemplate( |
| 833 | globals.fs.path.join('module' , 'ios' , 'host_app_ephemeral_cocoapods' ), |
| 834 | ephemeralModuleDirectory, |
| 835 | ); |
| 836 | } |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | @override |
| 841 | File get generatedXcodePropertiesFile => |
| 842 | _flutterLibRoot.childDirectory('Flutter' ).childFile('Generated.xcconfig' ); |
| 843 | |
| 844 | /// No longer compiled to this location. |
| 845 | /// |
| 846 | /// Used only for "flutter clean" to remove old references. |
| 847 | Directory get deprecatedCompiledDartFramework => |
| 848 | _flutterLibRoot.childDirectory('Flutter' ).childDirectory('App.framework' ); |
| 849 | |
| 850 | /// No longer copied to this location. |
| 851 | /// |
| 852 | /// Used only for "flutter clean" to remove old references. |
| 853 | Directory get deprecatedProjectFlutterFramework => |
| 854 | _flutterLibRoot.childDirectory('Flutter' ).childDirectory('Flutter.framework' ); |
| 855 | |
| 856 | /// Used only for "flutter clean" to remove old references. |
| 857 | File get flutterPodspec => _flutterLibRoot.childDirectory('Flutter' ).childFile('Flutter.podspec' ); |
| 858 | |
| 859 | Directory get pluginRegistrantHost { |
| 860 | return isModule |
| 861 | ? _flutterLibRoot.childDirectory('Flutter' ).childDirectory('FlutterPluginRegistrant' ) |
| 862 | : hostAppRoot.childDirectory(XcodeBasedProject._defaultHostAppName); |
| 863 | } |
| 864 | |
| 865 | File get pluginRegistrantHeader { |
| 866 | final Directory registryDirectory = isModule |
| 867 | ? pluginRegistrantHost.childDirectory('Classes' ) |
| 868 | : pluginRegistrantHost; |
| 869 | return registryDirectory.childFile('GeneratedPluginRegistrant.h' ); |
| 870 | } |
| 871 | |
| 872 | File get pluginRegistrantImplementation { |
| 873 | final Directory registryDirectory = isModule |
| 874 | ? pluginRegistrantHost.childDirectory('Classes' ) |
| 875 | : pluginRegistrantHost; |
| 876 | return registryDirectory.childFile('GeneratedPluginRegistrant.m' ); |
| 877 | } |
| 878 | |
| 879 | File get lldbInitFile { |
| 880 | return ephemeralDirectory.childFile('flutter_lldbinit' ); |
| 881 | } |
| 882 | |
| 883 | File get lldbHelperPythonFile { |
| 884 | return ephemeralDirectory.childFile(_lldbPythonHelperTemplateName); |
| 885 | } |
| 886 | |
| 887 | Future<void> _overwriteFromTemplate(String path, Directory target) async { |
| 888 | final Template template = await Template.fromName( |
| 889 | path, |
| 890 | fileSystem: globals.fs, |
| 891 | templateManifest: null, |
| 892 | logger: globals.logger, |
| 893 | templateRenderer: globals.templateRenderer, |
| 894 | ); |
| 895 | final String iosBundleIdentifier = |
| 896 | parent.manifest.iosBundleIdentifier ?? 'com.example. ${parent.manifest.appName}' ; |
| 897 | |
| 898 | final String? iosDevelopmentTeam = await getCodeSigningIdentityDevelopmentTeam( |
| 899 | processManager: globals.processManager, |
| 900 | platform: globals.platform, |
| 901 | logger: globals.logger, |
| 902 | config: globals.config, |
| 903 | terminal: globals.terminal, |
| 904 | fileSystem: globals.fs, |
| 905 | fileSystemUtils: globals.fsUtils, |
| 906 | plistParser: globals.plistParser, |
| 907 | ); |
| 908 | |
| 909 | final String projectName = parent.manifest.appName; |
| 910 | |
| 911 | // The dart project_name is in snake_case, this variable is the Title Case of the Project Name. |
| 912 | final String titleCaseProjectName = snakeCaseToTitleCase(projectName); |
| 913 | |
| 914 | template.render(target, <String, Object>{ |
| 915 | 'ios' : true, |
| 916 | 'projectName' : projectName, |
| 917 | 'titleCaseProjectName' : titleCaseProjectName, |
| 918 | 'iosIdentifier' : iosBundleIdentifier, |
| 919 | 'hasIosDevelopmentTeam' : iosDevelopmentTeam != null && iosDevelopmentTeam.isNotEmpty, |
| 920 | 'iosDevelopmentTeam' : iosDevelopmentTeam ?? '' , |
| 921 | }, printStatusWhenWriting: false); |
| 922 | } |
| 923 | } |
| 924 | |
| 925 | /// The macOS sub project. |
| 926 | class MacOSProject extends XcodeBasedProject { |
| 927 | MacOSProject.fromFlutter(this.parent); |
| 928 | |
| 929 | @override |
| 930 | final FlutterProject parent; |
| 931 | |
| 932 | @override |
| 933 | String get pluginConfigKey => MacOSPlugin.kConfigKey; |
| 934 | |
| 935 | @override |
| 936 | bool existsSync() => hostAppRoot.existsSync(); |
| 937 | |
| 938 | @override |
| 939 | Directory get hostAppRoot => parent.directory.childDirectory('macos' ); |
| 940 | |
| 941 | /// The xcfilelist used to track the inputs for the Flutter script phase in |
| 942 | /// the Xcode build. |
| 943 | File get inputFileList => ephemeralDirectory.childFile('FlutterInputs.xcfilelist' ); |
| 944 | |
| 945 | /// The xcfilelist used to track the outputs for the Flutter script phase in |
| 946 | /// the Xcode build. |
| 947 | File get outputFileList => ephemeralDirectory.childFile('FlutterOutputs.xcfilelist' ); |
| 948 | |
| 949 | @override |
| 950 | File get generatedXcodePropertiesFile => |
| 951 | ephemeralDirectory.childFile('Flutter-Generated.xcconfig' ); |
| 952 | |
| 953 | File get pluginRegistrantImplementation => |
| 954 | managedDirectory.childFile('GeneratedPluginRegistrant.swift' ); |
| 955 | |
| 956 | /// The 'AppDelegate.swift' file of the host app. This file might not exist if the app project uses Objective-C. |
| 957 | File get appDelegateSwift => hostAppRoot.childDirectory('Runner' ).childFile('AppDelegate.swift' ); |
| 958 | |
| 959 | @override |
| 960 | File xcodeConfigFor(String mode) => managedDirectory.childFile('Flutter- $mode.xcconfig' ); |
| 961 | |
| 962 | @override |
| 963 | File get generatedEnvironmentVariableExportScript => |
| 964 | ephemeralDirectory.childFile('flutter_export_environment.sh' ); |
| 965 | |
| 966 | /// The file where the Xcode build will write the name of the built app. |
| 967 | /// |
| 968 | /// Ideally this will be replaced in the future with inspection of the Runner |
| 969 | /// scheme's target. |
| 970 | File get nameFile => ephemeralDirectory.childFile('.app_filename' ); |
| 971 | |
| 972 | Future<void> ensureReadyForPlatformSpecificTooling() async { |
| 973 | // TODO(stuartmorgan): Add create-from-template logic here. |
| 974 | await _updateGeneratedXcodeConfigIfNeeded(); |
| 975 | } |
| 976 | |
| 977 | Future<void> _updateGeneratedXcodeConfigIfNeeded() async { |
| 978 | if (globals.cache.isOlderThanToolsStamp(generatedXcodePropertiesFile)) { |
| 979 | await xcode.updateGeneratedXcodeProperties( |
| 980 | project: parent, |
| 981 | buildInfo: BuildInfo.dummy, |
| 982 | useMacOSConfig: true, |
| 983 | ); |
| 984 | } |
| 985 | } |
| 986 | } |
| 987 | |