diff --git a/dev/devicelab/lib/framework/ios.dart b/dev/devicelab/lib/framework/ios.dart index ae47a65768..e50573e9b8 100644 --- a/dev/devicelab/lib/framework/ios.dart +++ b/dev/devicelab/lib/framework/ios.dart @@ -191,6 +191,13 @@ Future runXcodeTests({ codeSignStyle = environment['FLUTTER_XCODE_CODE_SIGN_STYLE']; provisioningProfile = environment['FLUTTER_XCODE_PROVISIONING_PROFILE_SPECIFIER']; } + File? disabledSandboxEntitlementFile; + if (platformDirectory.endsWith('macos')) { + disabledSandboxEntitlementFile = _createDisabledSandboxEntitlementFile( + platformDirectory, + configuration, + ); + } final String resultBundleTemp = Directory.systemTemp.createTempSync('flutter_xcresult.').path; final String resultBundlePath = path.join(resultBundleTemp, 'result'); final int testResultExit = await exec( @@ -214,6 +221,8 @@ Future runXcodeTests({ 'CODE_SIGN_STYLE=$codeSignStyle', if (provisioningProfile != null) 'PROVISIONING_PROFILE_SPECIFIER=$provisioningProfile', + if (disabledSandboxEntitlementFile != null) + 'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}', ], workingDirectory: platformDirectory, canFail: true, @@ -247,3 +256,55 @@ Future runXcodeTests({ } return true; } + +/// Finds and copies macOS entitlements file. In the copy, disables sandboxing. +/// If entitlements file is not found, returns null. +/// +/// As of macOS 14, testing a macOS sandbox app may prompt the user to grant +/// access to the app. To workaround this in CI, we create and use a entitlements +/// file with sandboxing disabled. See +/// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox. +File? _createDisabledSandboxEntitlementFile( + String platformDirectory, + String configuration, +) { + String entitlementDefaultFileName; + if (configuration == 'Release') { + entitlementDefaultFileName = 'Release'; + } else { + entitlementDefaultFileName = 'DebugProfile'; + } + + final String entitlementFilePath = path.join( + platformDirectory, + 'Runner', + '$entitlementDefaultFileName.entitlements', + ); + final File entitlementFile = File(entitlementFilePath); + + if (!entitlementFile.existsSync()) { + print('Unable to find entitlements file at ${entitlementFile.path}'); + return null; + } + + final String originalEntitlementFileContents = + entitlementFile.readAsStringSync(); + final String tempEntitlementPath = Directory.systemTemp + .createTempSync('flutter_disable_sandbox_entitlement.') + .path; + final File disabledSandboxEntitlementFile = File(path.join( + tempEntitlementPath, + '${entitlementDefaultFileName}WithDisabledSandboxing.entitlements', + )); + disabledSandboxEntitlementFile.createSync(recursive: true); + disabledSandboxEntitlementFile.writeAsStringSync( + originalEntitlementFileContents.replaceAll( + RegExp(r'com\.apple\.security\.app-sandbox<\/key>[\S\s]*?'), + ''' +com.apple.security.app-sandbox + ''', + ), + ); + + return disabledSandboxEntitlementFile; +} diff --git a/packages/flutter_tools/lib/src/commands/build_macos.dart b/packages/flutter_tools/lib/src/commands/build_macos.dart index f362593167..b3110a92fa 100644 --- a/packages/flutter_tools/lib/src/commands/build_macos.dart +++ b/packages/flutter_tools/lib/src/commands/build_macos.dart @@ -72,6 +72,7 @@ class BuildMacosCommand extends BuildSubCommand { flutterUsage: globals.flutterUsage, analytics: analytics, ), + usingCISystem: usingCISystem, ); return FlutterCommandResult.success(); } diff --git a/packages/flutter_tools/lib/src/desktop_device.dart b/packages/flutter_tools/lib/src/desktop_device.dart index 6931a1731a..1044ab2cae 100644 --- a/packages/flutter_tools/lib/src/desktop_device.dart +++ b/packages/flutter_tools/lib/src/desktop_device.dart @@ -16,6 +16,8 @@ import 'convert.dart'; import 'devfs.dart'; import 'device.dart'; import 'device_port_forwarder.dart'; +import 'globals.dart' as globals; +import 'macos/macos_device.dart'; import 'protocol_discovery.dart'; /// A partial implementation of Device for desktop-class devices to inherit @@ -119,6 +121,7 @@ abstract class DesktopDevice extends Device { await buildForDevice( buildInfo: debuggingOptions.buildInfo, mainPath: mainPath, + usingCISystem: debuggingOptions.usingCISystem, ); } @@ -159,8 +162,39 @@ abstract class DesktopDevice extends Device { logger: _logger, ); try { + Timer? timer; + if (this is MacOSDevice) { + if (await globals.isRunningOnBot) { + const int defaultTimeout = 5; + timer = Timer(const Duration(minutes: defaultTimeout), () { + // As of macOS 14, if sandboxing is enabled and the app is not codesigned, + // a dialog will prompt the user to allow the app to run. This will + // cause tests in CI to hang. In CI, we workaround this by setting + // the CODE_SIGN_ENTITLEMENTS build setting to a version with + // sandboxing disabled. + final String sandboxingMessage; + if (debuggingOptions.usingCISystem) { + sandboxingMessage = 'Ensure sandboxing is disabled by checking ' + 'the set CODE_SIGN_ENTITLEMENTS.'; + } else { + sandboxingMessage = 'Consider codesigning your app or disabling ' + 'sandboxing. Flutter will attempt to disable sandboxing if ' + 'the `--ci` flag is provided.'; + } + _logger.printError( + 'The Dart VM Service was not discovered after $defaultTimeout ' + 'minutes. If the app has sandboxing enabled and is not ' + 'codesigned or codesigning changed, this may be caused by a ' + 'system prompt asking for access. $sandboxingMessage\n' + 'See https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox ' + 'for more information.'); + }); + } + } + final Uri? vmServiceUri = await vmServiceDiscovery.uri; if (vmServiceUri != null) { + timer?.cancel(); onAttached(package, buildInfo, process); return LaunchResult.succeeded(vmServiceUri: vmServiceUri); } @@ -199,6 +233,7 @@ abstract class DesktopDevice extends Device { Future buildForDevice({ required BuildInfo buildInfo, String? mainPath, + bool usingCISystem = false, }); /// Returns the path to the executable to run for [package] on this device for diff --git a/packages/flutter_tools/lib/src/linux/linux_device.dart b/packages/flutter_tools/lib/src/linux/linux_device.dart index 7d210ee049..af5c2f93b2 100644 --- a/packages/flutter_tools/lib/src/linux/linux_device.dart +++ b/packages/flutter_tools/lib/src/linux/linux_device.dart @@ -62,6 +62,7 @@ class LinuxDevice extends DesktopDevice { Future buildForDevice({ String? mainPath, required BuildInfo buildInfo, + bool usingCISystem = false, }) async { await buildLinux( FlutterProject.current().linux, diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 575b60d5c1..4d971330fb 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -65,6 +65,7 @@ Future buildMacOS({ required bool verboseLogging, bool configOnly = false, SizeAnalyzer? sizeAnalyzer, + bool usingCISystem = false, }) async { final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; if (xcodeWorkspace == null) { @@ -153,6 +154,19 @@ Future buildMacOS({ 'Building macOS application...', ); int result; + + File? disabledSandboxEntitlementFile; + if (usingCISystem) { + disabledSandboxEntitlementFile = _createDisabledSandboxEntitlementFile( + flutterProject.macos, + configuration, + ); + if (disabledSandboxEntitlementFile != null) { + globals.logger.printStatus( + 'Detected macOS app running in CI, turning off sandboxing.'); + } + } + try { result = await globals.processUtils.stream([ '/usr/bin/env', @@ -170,6 +184,8 @@ Future buildMacOS({ else '-quiet', 'COMPILER_INDEX_STORE_ENABLE=NO', + if (disabledSandboxEntitlementFile != null) + 'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}', ...environmentVariablesAsXcodeBuildSettings(globals.platform), ], trace: true, @@ -271,3 +287,52 @@ Future _writeCodeSizeAnalysis(BuildInfo buildInfo, SizeAnalyzer? sizeAnaly 'dart devtools --appSizeBase=$relativeAppSizePath' ); } + +/// Finds and copies macOS entitlements file. In the copy, disables sandboxing. +/// If entitlements file is not found, returns null. +/// +/// As of macOS 14, running a macOS sandbox app may prompt the user to grant +/// access to the app. To workaround this in CI, we create and use a entitlements +/// file with sandboxing disabled. See +/// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox. +File? _createDisabledSandboxEntitlementFile( + MacOSProject macos, + String configuration, +) { + String entitlementDefaultFileName; + if (configuration == 'Release') { + entitlementDefaultFileName = 'Release'; + } else { + entitlementDefaultFileName = 'DebugProfile'; + } + + // TODO(vashworth): Once https://github.com/flutter/flutter/issues/146204 is + // fixed, it would be better to get the path to the entitlement file from the + // project's build settings (CODE_SIGN_ENTITLEMENTS). + final File entitlementFile = macos.hostAppRoot + .childDirectory('Runner') + .childFile('$entitlementDefaultFileName.entitlements'); + + if (!entitlementFile.existsSync()) { + globals.logger.printTrace( + 'Unable to find entitlements file at ${entitlementFile.path}'); + return null; + } + + final String entitlementFileContents = entitlementFile.readAsStringSync(); + final File disabledSandboxEntitlementFile = globals.fs.systemTempDirectory + .createTempSync('flutter_disable_sandbox_entitlement.') + .childFile( + '${entitlementDefaultFileName}WithDisabledSandboxing.entitlements', + ); + disabledSandboxEntitlementFile.createSync(recursive: true); + disabledSandboxEntitlementFile.writeAsStringSync( + entitlementFileContents.replaceAll( + RegExp(r'com\.apple\.security\.app-sandbox<\/key>[\S\s]*?'), + ''' +com.apple.security.app-sandbox + ''', + ), + ); + return disabledSandboxEntitlementFile; +} diff --git a/packages/flutter_tools/lib/src/macos/macos_device.dart b/packages/flutter_tools/lib/src/macos/macos_device.dart index 8ca2f6bd55..63c4a2dac6 100644 --- a/packages/flutter_tools/lib/src/macos/macos_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_device.dart @@ -70,12 +70,14 @@ class MacOSDevice extends DesktopDevice { Future buildForDevice({ required BuildInfo buildInfo, String? mainPath, + bool usingCISystem = false, }) async { await buildMacOS( flutterProject: FlutterProject.current(), buildInfo: buildInfo, targetOverride: mainPath, verboseLogging: _logger.isVerbose, + usingCISystem: usingCISystem, ); } diff --git a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart index e61cc88dae..235d01383d 100644 --- a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart @@ -116,6 +116,7 @@ class MacOSDesignedForIPadDevice extends DesktopDevice { Future buildForDevice({ String? mainPath, required BuildInfo buildInfo, + bool usingCISystem = false, }) async { // Only attaching to a running app launched from Xcode is supported. throw UnimplementedError('Building for "$name" is not supported.'); diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 8a30f61ff1..2a6d6cd2bd 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -376,7 +376,16 @@ abstract class FlutterCommand extends Command { String? get packagesPath => stringArg(FlutterGlobalOptions.kPackagesOption, global: true); /// Whether flutter is being run from our CI. - bool get usingCISystem => boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true); + /// + /// This is true if `--ci` is passed to the command or if environment + /// variable `LUCI_CI` is `True`. + bool get usingCISystem { + return boolArg( + FlutterGlobalOptions.kContinuousIntegrationFlag, + global: true, + ) || + globals.platform.environment['LUCI_CI'] == 'True'; + } String? get debugLogsDirectoryPath => stringArg(FlutterGlobalOptions.kDebugLogsDirectoryFlag, global: true); diff --git a/packages/flutter_tools/lib/src/windows/windows_device.dart b/packages/flutter_tools/lib/src/windows/windows_device.dart index b7be891e09..4bf01cc3b7 100644 --- a/packages/flutter_tools/lib/src/windows/windows_device.dart +++ b/packages/flutter_tools/lib/src/windows/windows_device.dart @@ -60,6 +60,7 @@ class WindowsDevice extends DesktopDevice { Future buildForDevice({ String? mainPath, required BuildInfo buildInfo, + bool usingCISystem = false, }) async { await buildWindows( FlutterProject.current().windows, diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart index d3245980ba..bf1efcba14 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:args/command_runner.dart'; import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; @@ -109,7 +110,12 @@ void main() { // Creates a FakeCommand for the xcodebuild call to build the app // in the given configuration. - FakeCommand setUpFakeXcodeBuildHandler(String configuration, { bool verbose = false, void Function(List command)? onRun }) { + FakeCommand setUpFakeXcodeBuildHandler( + String configuration, { + bool verbose = false, + void Function(List command)? onRun, + List? additionalCommandArguements, + }) { final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); final Directory flutterBuildDir = fileSystem.directory(getMacOSBuildDirectory()); return FakeCommand( @@ -129,6 +135,8 @@ void main() { else '-quiet', 'COMPILER_INDEX_STORE_ENABLE=NO', + if (additionalCommandArguements != null) + ...additionalCommandArguements, ], stdout: ''' STDOUT STUFF @@ -706,4 +714,136 @@ STDERR STUFF Usage: () => usage, Analytics: () => fakeAnalytics, }); + + testUsingContext('macOS build overrides CODE_SIGN_ENTITLEMENTS when in CI if entitlement file exists (debug)', () async { + final BuildCommand command = BuildCommand( + artifacts: artifacts, + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + processUtils: processUtils, + osUtils: FakeOperatingSystemUtils(), + ); + createMinimalMockProjectFiles(); + + final File entitlementFile = fileSystem.file(fileSystem.path.join('macos', 'Runner', 'DebugProfile.entitlements')); + entitlementFile.createSync(recursive: true); + entitlementFile.writeAsStringSync(''' + + + + + com.apple.security.app-sandbox + + + + +'''); + + await createTestCommandRunner(command).run( + const ['build', 'macos', '--debug', '--no-pub'] + ); + + final File tempEntitlementFile = fileSystem.systemTempDirectory.childFile('flutter_disable_sandbox_entitlement.rand0/DebugProfileWithDisabledSandboxing.entitlements'); + expect(tempEntitlementFile, exists); + expect(tempEntitlementFile.readAsStringSync(), ''' + + + + + com.apple.security.app-sandbox + + + + +'''); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + setUpFakeXcodeBuildHandler( + 'Debug', + additionalCommandArguements: [ + 'CODE_SIGN_ENTITLEMENTS=/.tmp_rand0/flutter_disable_sandbox_entitlement.rand0/DebugProfileWithDisabledSandboxing.entitlements', + ], + ), + ]), + Platform: () => FakePlatform( + operatingSystem: 'macos', + environment: { + 'FLUTTER_ROOT': '/', + 'HOME': '/', + 'LUCI_CI': 'True' + } + ), + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + }); + + testUsingContext('macOS build overrides CODE_SIGN_ENTITLEMENTS when in CI if entitlement file exists (release)', () async { + final BuildCommand command = BuildCommand( + artifacts: artifacts, + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + processUtils: processUtils, + osUtils: FakeOperatingSystemUtils(), + ); + createMinimalMockProjectFiles(); + + final File entitlementFile = fileSystem.file( + fileSystem.path.join('macos', 'Runner', 'Release.entitlements'), + ); + entitlementFile.createSync(recursive: true); + entitlementFile.writeAsStringSync(''' + + + + + com.apple.security.app-sandbox + + + + +'''); + + await createTestCommandRunner(command).run( + const ['build', 'macos', '--release', '--no-pub'] + ); + + final File tempEntitlementFile = fileSystem.systemTempDirectory.childFile( + 'flutter_disable_sandbox_entitlement.rand0/ReleaseWithDisabledSandboxing.entitlements', + ); + expect(tempEntitlementFile, exists); + expect(tempEntitlementFile.readAsStringSync(), ''' + + + + + com.apple.security.app-sandbox + + + + +'''); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + setUpFakeXcodeBuildHandler( + 'Release', + additionalCommandArguements: [ + 'CODE_SIGN_ENTITLEMENTS=/.tmp_rand0/flutter_disable_sandbox_entitlement.rand0/ReleaseWithDisabledSandboxing.entitlements', + ], + ), + ]), + Platform: () => FakePlatform( + operatingSystem: 'macos', + environment: { + 'FLUTTER_ROOT': '/', + 'HOME': '/', + 'LUCI_CI': 'True' + } + ), + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + }); } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart index 4d74c30b3a..19aa4a542d 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_tools/src/base/common.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/terminal.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -1201,6 +1202,26 @@ void main() { ProcessManager: () => FakeProcessManager.any(), }); + testUsingContext('usingCISystem can also be set by environment LUCI_CI', () async { + final RunCommand command = RunCommand(); + await expectLater(() => createTestCommandRunner(command).run([ + 'run', + ]), throwsToolExit()); + + final DebuggingOptions options = await command.createDebuggingOptions(false); + + expect(options.usingCISystem, true); + }, overrides: { + Cache: () => Cache.test(processManager: FakeProcessManager.any()), + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + Platform: () => FakePlatform( + environment: { + 'LUCI_CI': 'True' + } + ), + }); + testUsingContext('wasm mode selects skwasm renderer by default', () async { final RunCommand command = RunCommand(); await expectLater(() => createTestCommandRunner(command).run([ diff --git a/packages/flutter_tools/test/general.shard/desktop_device_test.dart b/packages/flutter_tools/test/general.shard/desktop_device_test.dart index b42194d828..e6e469c50f 100644 --- a/packages/flutter_tools/test/general.shard/desktop_device_test.dart +++ b/packages/flutter_tools/test/general.shard/desktop_device_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/base/file_system.dart'; @@ -14,12 +15,13 @@ import 'package:flutter_tools/src/desktop_device.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.dart'; +import 'package:flutter_tools/src/macos/macos_device.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:test/fake.dart'; import '../src/common.dart'; -import '../src/fake_process_manager.dart'; +import '../src/context.dart'; void main() { group('Basic info', () { @@ -364,6 +366,33 @@ void main() { ), ); }); + + testUsingContext('macOS devices print warning if Dart VM not found within timeframe in CI', () async { + final BufferLogger logger = BufferLogger.test(); + final FakeMacOSDevice device = FakeMacOSDevice( + fileSystem: MemoryFileSystem.test(), + processManager: FakeProcessManager.any(), + operatingSystemUtils: FakeOperatingSystemUtils(), + logger: logger, + ); + + final FakeApplicationPackage package = FakeApplicationPackage(); + + FakeAsync().run((FakeAsync fakeAsync) { + device.startApp( + package, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled( + BuildInfo.debug, + enableImpeller: ImpellerStatus.disabled, + dartEntrypointArgs: [], + usingCISystem: true, + ), + ); + fakeAsync.flushTimers(); + expect(logger.errorText, contains('Ensure sandboxing is disabled by checking the set CODE_SIGN_ENTITLEMENTS')); + }); + }); } FakeDesktopDevice setUpDesktopDevice({ @@ -424,6 +453,7 @@ class FakeDesktopDevice extends DesktopDevice { Future buildForDevice({ String? mainPath, BuildInfo? buildInfo, + bool usingCISystem = false, }) async { lastBuiltMainPath = mainPath; lastBuildInfo = buildInfo; @@ -444,3 +474,38 @@ class FakeOperatingSystemUtils extends Fake implements OperatingSystemUtils { @override String get name => 'Example'; } + +class FakeMacOSDevice extends MacOSDevice { + FakeMacOSDevice({ + required super.processManager, + required super.logger, + required super.fileSystem, + required super.operatingSystemUtils, + }); + + @override + String get name => 'dummy'; + + @override + Future get targetPlatform async => TargetPlatform.tester; + + @override + bool isSupported() => true; + + @override + bool isSupportedForProject(FlutterProject flutterProject) => true; + + @override + Future buildForDevice({ + String? mainPath, + BuildInfo? buildInfo, + bool usingCISystem = false, + }) async { + } + + // Dummy implementation that just returns the build mode name. + @override + String? executablePathForDevice(ApplicationPackage package, BuildInfo buildInfo) { + return buildInfo.mode.cliName; + } +}