From 40843e3e6133670d35279e7625cf7cf3bac40370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Sharma?= <737941+loic-sharma@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:02:08 -0700 Subject: [PATCH] Update minimum macOS version as needed in Swift package (#152347) If Swift Package Manager is enabled, the tool generates a Swift package at `/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/`. This Swift package is how the tool adds plugins to the Flutter project. SwiftPM is strictly enforces platform versions: you cannot depend on a Swift package if its supported version is higher than your own. On iOS, we use the project's minimum deployment version for the generated Swift package. If a plugin has a higher requirement, you'll need to update your project's minimum deployment version. The generated Swift package is automatically updated the next time you run the tool. This updates macOS to do the same thing. Fixes https://github.com/flutter/flutter/issues/146204 --- .../flutter_tools/lib/src/ios/xcodeproj.dart | 70 +++++--- .../lib/src/macos/build_macos.dart | 62 ++++--- .../flutter_tools/lib/src/xcode_project.dart | 157 +++++++++--------- .../general.shard/ios/xcodeproj_test.dart | 52 +++++- .../test/general.shard/project_test.dart | 4 +- .../swift_package_manager_test.dart | 132 +++++++++++++++ 6 files changed, 349 insertions(+), 128 deletions(-) diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index 773c7356f42..6d43076175e 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -195,6 +195,11 @@ class XcodeProjectInterpreter { final String? configuration = buildContext.configuration; final String? target = buildContext.target; final String? deviceId = buildContext.deviceId; + final String buildDir = switch (buildContext.sdk) { + XcodeSdk.MacOSX => getMacOSBuildDirectory(), + XcodeSdk.IPhoneOS || XcodeSdk.IPhoneSimulator => getIosBuildDirectory(), + XcodeSdk.WatchOS || XcodeSdk.WatchSimulator => getIosBuildDirectory(), + }; final List showBuildSettingsCommand = [ ...xcrunCommand(), 'xcodebuild', @@ -206,21 +211,20 @@ class XcodeProjectInterpreter { ...['-configuration', configuration], if (target != null) ...['-target', target], - if (buildContext.environmentType == EnvironmentType.simulator) + if (buildContext.sdk == XcodeSdk.IPhoneSimulator || buildContext.sdk == XcodeSdk.WatchSimulator) ...['-sdk', 'iphonesimulator'], '-destination', - if (buildContext.isWatch && buildContext.environmentType == EnvironmentType.physical) - 'generic/platform=watchOS' - else if (buildContext.isWatch) - 'generic/platform=watchOS Simulator' - else if (deviceId != null) + if (deviceId != null) 'id=$deviceId' - else if (buildContext.environmentType == EnvironmentType.physical) - 'generic/platform=iOS' - else - 'generic/platform=iOS Simulator', + else switch (buildContext.sdk) { + XcodeSdk.IPhoneOS => 'generic/platform=iOS', + XcodeSdk.IPhoneSimulator => 'generic/platform=iOS Simulator', + XcodeSdk.MacOSX => 'generic/platform=macOS', + XcodeSdk.WatchOS => 'generic/platform=watchOS', + XcodeSdk.WatchSimulator => 'generic/platform=watchOS Simulator', + }, '-showBuildSettings', - 'BUILD_DIR=${_fileSystem.path.absolute(getIosBuildDirectory())}', + 'BUILD_DIR=${_fileSystem.path.absolute(buildDir)}', ...environmentVariablesAsXcodeBuildSettings(_platform), ]; try { @@ -238,14 +242,19 @@ class XcodeProjectInterpreter { return parseXcodeBuildSettings(out); } on Exception catch (error) { if (error is ProcessException && error.toString().contains('timed out')) { + final String eventType = switch (buildContext.sdk) { + XcodeSdk.MacOSX => 'macos', + XcodeSdk.IPhoneOS || XcodeSdk.IPhoneSimulator => 'ios', + XcodeSdk.WatchOS || XcodeSdk.WatchSimulator => 'watchos', + }; BuildEvent('xcode-show-build-settings-timeout', - type: 'ios', + type: eventType, command: showBuildSettingsCommand.join(' '), flutterUsage: _usage, ).send(); _analytics.send(Event.flutterBuildInfo( label: 'xcode-show-build-settings-timeout', - buildType: 'ios', + buildType: eventType, command: showBuildSettingsCommand.join(' '), )); } @@ -394,26 +403,40 @@ String substituteXcodeVariables(String str, Map xcodeBuildSettin return str.replaceAllMapped(_varExpr, (Match m) => xcodeBuildSettings[m[1]!] ?? m[0]!); } +/// Xcode SDKs. Corresponds to undocumented Xcode SUPPORTED_PLATFORMS values. +/// Use `xcodebuild -showsdks` to get a list of SDKs installed on your machine. +enum XcodeSdk { + IPhoneOS, + IPhoneSimulator, + MacOSX, + WatchOS, + WatchSimulator, +} + @immutable class XcodeProjectBuildContext { const XcodeProjectBuildContext({ this.scheme, this.configuration, - this.environmentType = EnvironmentType.physical, + this.sdk = XcodeSdk.IPhoneOS, this.deviceId, this.target, - this.isWatch = false, }); final String? scheme; final String? configuration; - final EnvironmentType environmentType; + final XcodeSdk sdk; final String? deviceId; final String? target; - final bool isWatch; @override - int get hashCode => Object.hash(scheme, configuration, environmentType, deviceId, target); + int get hashCode => Object.hash( + scheme, + configuration, + sdk, + deviceId, + target, + ); @override bool operator ==(Object other) { @@ -421,12 +444,11 @@ class XcodeProjectBuildContext { return true; } return other is XcodeProjectBuildContext && - other.scheme == scheme && - other.configuration == configuration && - other.deviceId == deviceId && - other.environmentType == environmentType && - other.isWatch == isWatch && - other.target == target; + other.scheme == scheme && + other.configuration == configuration && + other.deviceId == deviceId && + other.sdk == sdk && + other.target == target; } } diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index ea4204c0bf3..4a13eda6fbd 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -28,6 +28,7 @@ import 'migrations/macos_deployment_target_migration.dart'; import 'migrations/nsapplicationmain_deprecation_migration.dart'; import 'migrations/remove_macos_framework_link_and_embedding_migration.dart'; import 'migrations/secure_restorable_state_migration.dart'; +import 'swift_package_manager.dart'; /// When run in -quiet mode, Xcode should only print from the underlying tasks to stdout. /// Passing this regexp to trace moves the stdout output to stderr. @@ -108,29 +109,6 @@ Future buildMacOS({ if (!flutterBuildDir.existsSync()) { flutterBuildDir.createSync(recursive: true); } - // Write configuration to an xconfig file in a standard location. - await updateGeneratedXcodeProperties( - project: flutterProject, - buildInfo: buildInfo, - targetOverride: targetOverride, - useMacOSConfig: true, - ); - - // TODO(vashworth): Call `SwiftPackageManager.updateMinimumDeployment` - // using MACOSX_DEPLOYMENT_TARGET once https://github.com/flutter/flutter/issues/146204 - // is fixed. - - await processPodsIfNeeded(flutterProject.macos, getMacOSBuildDirectory(), buildInfo.mode); - // If the xcfilelists do not exist, create empty version. - if (!flutterProject.macos.inputFileList.existsSync()) { - flutterProject.macos.inputFileList.createSync(recursive: true); - } - if (!flutterProject.macos.outputFileList.existsSync()) { - flutterProject.macos.outputFileList.createSync(recursive: true); - } - if (configOnly) { - return; - } final Directory xcodeProject = flutterProject.macos.xcodeProject; @@ -150,6 +128,44 @@ Future buildMacOS({ if (configuration == null) { throwToolExit('Unable to find expected configuration in Xcode project.'); } + + final Map buildSettings = await flutterProject.macos.buildSettingsForBuildInfo( + buildInfo, + scheme: scheme, + configuration: configuration, + ) ?? {}; + + // Write configuration to an xconfig file in a standard location. + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: buildInfo, + targetOverride: targetOverride, + useMacOSConfig: true, + ); + + if (flutterProject.usesSwiftPackageManager) { + final String? macOSDeploymentTarget = buildSettings['MACOSX_DEPLOYMENT_TARGET']; + if (macOSDeploymentTarget != null) { + SwiftPackageManager.updateMinimumDeployment( + platform: SupportedPlatform.macos, + project: flutterProject.macos, + deploymentTarget: macOSDeploymentTarget, + ); + } + } + + await processPodsIfNeeded(flutterProject.macos, getMacOSBuildDirectory(), buildInfo.mode); + // If the xcfilelists do not exist, create empty version. + if (!flutterProject.macos.inputFileList.existsSync()) { + flutterProject.macos.inputFileList.createSync(recursive: true); + } + if (!flutterProject.macos.outputFileList.existsSync()) { + flutterProject.macos.outputFileList.createSync(recursive: true); + } + if (configOnly) { + return; + } + // Run the Xcode build. final Stopwatch sw = Stopwatch()..start(); final Status status = globals.logger.startProgress( diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index 375db04a3dc..85c135cf97d 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -155,6 +155,89 @@ abstract class XcodeBasedProject extends FlutterProjectPlatform { return _projectInfo ??= await xcodeProjectInterpreter.getInfo(hostAppRoot.path); } XcodeProjectInfo? _projectInfo; + + /// The build settings for the host app of this project, as a detached map. + /// + /// Returns null, if Xcode tooling is unavailable. + Future?> buildSettingsForBuildInfo( + BuildInfo? buildInfo, { + String? scheme, + String? configuration, + String? target, + EnvironmentType environmentType = EnvironmentType.physical, + String? deviceId, + bool isWatch = false, + }) async { + if (!existsSync()) { + return null; + } + final XcodeProjectInfo? info = await projectInfo(); + if (info == null) { + return null; + } + + scheme ??= info.schemeFor(buildInfo); + if (scheme == null) { + info.reportFlavorNotFoundAndExit(); + } + + configuration ??= (await projectInfo())?.buildConfigurationFor( + buildInfo, + scheme, + ); + + final XcodeSdk sdk = switch ((environmentType, this)) { + (EnvironmentType.physical, _) when isWatch => XcodeSdk.WatchOS, + (EnvironmentType.simulator, _) when isWatch => XcodeSdk.WatchSimulator, + (EnvironmentType.physical, IosProject _) => XcodeSdk.IPhoneOS, + (EnvironmentType.simulator, IosProject _) => XcodeSdk.WatchSimulator, + (EnvironmentType.physical, MacOSProject _) => XcodeSdk.MacOSX, + (_, _) => throw ArgumentError('Unsupported SDK') + }; + + return _buildSettingsForXcodeProjectBuildContext( + XcodeProjectBuildContext( + scheme: scheme, + configuration: configuration, + sdk: sdk, + target: target, + deviceId: deviceId, + ), + ); + } + + Future?> _buildSettingsForXcodeProjectBuildContext(XcodeProjectBuildContext buildContext) async { + if (!existsSync()) { + return null; + } + final Map? currentBuildSettings = _buildSettingsByBuildContext[buildContext]; + if (currentBuildSettings == null) { + final Map? calculatedBuildSettings = await _xcodeProjectBuildSettings(buildContext); + if (calculatedBuildSettings != null) { + _buildSettingsByBuildContext[buildContext] = calculatedBuildSettings; + } + } + return _buildSettingsByBuildContext[buildContext]; + } + + final Map> _buildSettingsByBuildContext = >{}; + + Future?> _xcodeProjectBuildSettings(XcodeProjectBuildContext buildContext) async { + final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; + if (xcodeProjectInterpreter == null || !xcodeProjectInterpreter.isInstalled) { + return null; + } + + final Map buildSettings = await xcodeProjectInterpreter.getBuildSettings( + xcodeProject.path, + buildContext: buildContext, + ); + if (buildSettings.isNotEmpty) { + // No timeouts, flakes, or errors. + return buildSettings; + } + return null; + } } /// Represents the iOS sub-project of a Flutter project. @@ -424,80 +507,6 @@ class IosProject extends XcodeBasedProject { return productName ?? XcodeBasedProject._defaultHostAppName; } - /// The build settings for the host app of this project, as a detached map. - /// - /// Returns null, if iOS tooling is unavailable. - Future?> buildSettingsForBuildInfo( - BuildInfo? buildInfo, { - String? scheme, - String? configuration, - String? target, - EnvironmentType environmentType = EnvironmentType.physical, - String? deviceId, - bool isWatch = false, - }) async { - if (!existsSync()) { - return null; - } - final XcodeProjectInfo? info = await projectInfo(); - if (info == null) { - return null; - } - - scheme ??= info.schemeFor(buildInfo); - if (scheme == null) { - info.reportFlavorNotFoundAndExit(); - } - - configuration ??= (await projectInfo())?.buildConfigurationFor( - buildInfo, - scheme, - ); - return _buildSettingsForXcodeProjectBuildContext( - XcodeProjectBuildContext( - environmentType: environmentType, - scheme: scheme, - configuration: configuration, - target: target, - deviceId: deviceId, - isWatch: isWatch, - ), - ); - } - - Future?> _buildSettingsForXcodeProjectBuildContext(XcodeProjectBuildContext buildContext) async { - if (!existsSync()) { - return null; - } - final Map? currentBuildSettings = _buildSettingsByBuildContext[buildContext]; - if (currentBuildSettings == null) { - final Map? calculatedBuildSettings = await _xcodeProjectBuildSettings(buildContext); - if (calculatedBuildSettings != null) { - _buildSettingsByBuildContext[buildContext] = calculatedBuildSettings; - } - } - return _buildSettingsByBuildContext[buildContext]; - } - - final Map> _buildSettingsByBuildContext = >{}; - - Future?> _xcodeProjectBuildSettings(XcodeProjectBuildContext buildContext) async { - final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; - if (xcodeProjectInterpreter == null || !xcodeProjectInterpreter.isInstalled) { - return null; - } - - final Map buildSettings = await xcodeProjectInterpreter.getBuildSettings( - xcodeProject.path, - buildContext: buildContext, - ); - if (buildSettings.isNotEmpty) { - // No timeouts, flakes, or errors. - return buildSettings; - } - return null; - } - Future ensureReadyForPlatformSpecificTooling() async { await _regenerateFromTemplateIfNeeded(); if (!_flutterLibRoot.existsSync()) { diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index 7563ab77d49..9a5fce06713 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -325,7 +325,9 @@ void main() { expect( await xcodeProjectInterpreter.getBuildSettings( '', - buildContext: const XcodeProjectBuildContext(environmentType: EnvironmentType.simulator), + buildContext: const XcodeProjectBuildContext( + sdk: XcodeSdk.IPhoneSimulator, + ), ), const {}, ); @@ -398,7 +400,7 @@ void main() { ProcessManager: () => FakeProcessManager.any(), }); - testUsingContext('build settings uses watch destination if isWatch is true', () async { + testUsingContext('build settings uses watch destination', () async { platform.environment = const {}; fakeProcessManager.addCommands([ @@ -422,7 +424,9 @@ void main() { expect( await xcodeProjectInterpreter.getBuildSettings( '', - buildContext: const XcodeProjectBuildContext(isWatch: true), + buildContext: const XcodeProjectBuildContext( + sdk: XcodeSdk.WatchOS, + ), ), const {}, ); @@ -432,7 +436,7 @@ void main() { ProcessManager: () => FakeProcessManager.any(), }); - testUsingContext('build settings uses watch simulator destination if isWatch is true and environment type is simulator', () async { + testUsingContext('build settings uses watch simulator destination', () async { platform.environment = const {}; fakeProcessManager.addCommands([ @@ -458,7 +462,45 @@ void main() { expect( await xcodeProjectInterpreter.getBuildSettings( '', - buildContext: const XcodeProjectBuildContext(environmentType: EnvironmentType.simulator, isWatch: true), + buildContext: const XcodeProjectBuildContext( + sdk: XcodeSdk.WatchSimulator, + ), + ), + const {}, + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('build settings uses macosx destination', () async { + platform.environment = const {}; + + fakeProcessManager.addCommands([ + kWhichSysctlCommand, + kx64CheckCommand, + FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-project', + '/', + '-destination', + 'generic/platform=macOS', + '-showBuildSettings', + 'BUILD_DIR=${fileSystem.path.absolute('build', 'macos')}', + ], + exitCode: 1, + ), + ]); + + expect( + await xcodeProjectInterpreter.getBuildSettings( + '', + buildContext: const XcodeProjectBuildContext( + sdk: XcodeSdk.MacOSX, + ), ), const {}, ); diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart index f624982cf3a..84978ae29f7 100644 --- a/packages/flutter_tools/test/general.shard/project_test.dart +++ b/packages/flutter_tools/test/general.shard/project_test.dart @@ -1458,7 +1458,7 @@ plugins { const XcodeProjectBuildContext watchBuildContext = XcodeProjectBuildContext( scheme: 'WatchScheme', deviceId: '123', - isWatch: true, + sdk: XcodeSdk.WatchOS, ); mockXcodeProjectInterpreter.buildSettingsByBuildContext[watchBuildContext] = { 'INFOPLIST_KEY_WKCompanionAppBundleIdentifier': 'io.flutter.someProject', @@ -1498,7 +1498,7 @@ plugins { const XcodeProjectBuildContext watchBuildContext = XcodeProjectBuildContext( scheme: 'WatchScheme', deviceId: '123', - isWatch: true, + sdk: XcodeSdk.WatchOS, ); mockXcodeProjectInterpreter.buildSettingsByBuildContext[watchBuildContext] = { IosProject.kProductBundleIdKey: 'io.flutter.someProject', diff --git a/packages/flutter_tools/test/integration.shard/swift_package_manager_test.dart b/packages/flutter_tools/test/integration.shard/swift_package_manager_test.dart index 3dbc4897334..2460fdece56 100644 --- a/packages/flutter_tools/test/integration.shard/swift_package_manager_test.dart +++ b/packages/flutter_tools/test/integration.shard/swift_package_manager_test.dart @@ -475,4 +475,136 @@ void main() { ); } }, skip: !platform.isMacOS); // [intended] Swift Package Manager only works on macos. + + test("Generated Swift package uses iOS's project minimum deployment", () async { + final Directory workingDirectory = fileSystem.systemTempDirectory + .createTempSync('swift_package_manager_minimum_deployment_ios.'); + final String workingDirectoryPath = workingDirectory.path; + try { + await SwiftPackageManagerUtils.enableSwiftPackageManager(flutterBin, workingDirectoryPath); + final String appDirectoryPath = await SwiftPackageManagerUtils.createApp( + flutterBin, + workingDirectoryPath, + iosLanguage: 'swift', + platform: 'ios', + usesSwiftPackageManager: true, + options: ['--platforms=ios'], + ); + + // Modify the project to raise the deployment version. + final File projectFile = fileSystem + .directory(appDirectoryPath) + .childDirectory('ios') + .childDirectory('Runner.xcodeproj') + .childFile('project.pbxproj'); + + final String oldProject = projectFile.readAsStringSync(); + final String newProject = oldProject.replaceAll( + RegExp(r'IPHONEOS_DEPLOYMENT_TARGET = \d+\.\d+;'), + 'IPHONEOS_DEPLOYMENT_TARGET = 15.1;', + ); + + projectFile.writeAsStringSync(newProject); + + // Build the app. This generates Flutter's Swift package. + await SwiftPackageManagerUtils.buildApp( + flutterBin, + appDirectoryPath, + options: ['ios', '--debug', '-v'], + ); + + // Verify the generated Swift package uses the project's minimum deployment. + final File generatedManifestFile = fileSystem + .directory(appDirectoryPath) + .childDirectory('ios') + .childDirectory('Flutter') + .childDirectory('ephemeral') + .childDirectory('Packages') + .childDirectory('FlutterGeneratedPluginSwiftPackage') + .childFile('Package.swift'); + + expect(generatedManifestFile.existsSync(), isTrue); + + final String generatedManifest = generatedManifestFile.readAsStringSync(); + const String expected = ''' + platforms: [ + .iOS("15.1") + ], +'''; + + expect(generatedManifest.contains(expected), isTrue); + } finally { + await SwiftPackageManagerUtils.disableSwiftPackageManager(flutterBin, workingDirectoryPath); + ErrorHandlingFileSystem.deleteIfExists( + workingDirectory, + recursive: true, + ); + } + }, skip: !platform.isMacOS); // [intended] Swift Package Manager only works on macos. + + test("Generated Swift package uses macOS's project minimum deployment", () async { + final Directory workingDirectory = fileSystem.systemTempDirectory + .createTempSync('swift_package_manager_minimum_deployment_macos.'); + final String workingDirectoryPath = workingDirectory.path; + try { + await SwiftPackageManagerUtils.enableSwiftPackageManager(flutterBin, workingDirectoryPath); + final String appDirectoryPath = await SwiftPackageManagerUtils.createApp( + flutterBin, + workingDirectoryPath, + iosLanguage: 'swift', + platform: 'macos', + usesSwiftPackageManager: true, + options: ['--platforms=macos'], + ); + + // Modify the project to raise the deployment version. + final File projectFile = fileSystem + .directory(appDirectoryPath) + .childDirectory('macos') + .childDirectory('Runner.xcodeproj') + .childFile('project.pbxproj'); + + final String oldProject = projectFile.readAsStringSync(); + final String newProject = oldProject.replaceAll( + RegExp(r'MACOSX_DEPLOYMENT_TARGET = \d+\.\d+;'), + 'MACOSX_DEPLOYMENT_TARGET = 15.1;', + ); + + projectFile.writeAsStringSync(newProject); + + // Build the app. This generates Flutter's Swift package. + await SwiftPackageManagerUtils.buildApp( + flutterBin, + appDirectoryPath, + options: ['macos', '--debug', '-v'], + ); + + // Verify the generated Swift package uses the project's minimum deployment. + final File generatedManifestFile = fileSystem + .directory(appDirectoryPath) + .childDirectory('macos') + .childDirectory('Flutter') + .childDirectory('ephemeral') + .childDirectory('Packages') + .childDirectory('FlutterGeneratedPluginSwiftPackage') + .childFile('Package.swift'); + + expect(generatedManifestFile.existsSync(), isTrue); + + final String generatedManifest = generatedManifestFile.readAsStringSync(); + const String expected = ''' + platforms: [ + .macOS("15.1") + ], +'''; + + expect(generatedManifest.contains(expected), isTrue); + } finally { + await SwiftPackageManagerUtils.disableSwiftPackageManager(flutterBin, workingDirectoryPath); + ErrorHandlingFileSystem.deleteIfExists( + workingDirectory, + recursive: true, + ); + } + }, skip: !platform.isMacOS); // [intended] Swift Package Manager only works on macos. }