| // Copyright 2013 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:convert'; | |
| import 'dart:io' as io; | |
| import 'package:file/file.dart'; | |
| import 'package:platform/platform.dart'; | |
| import 'core.dart'; | |
| import 'output_utils.dart'; | |
| import 'process_runner.dart'; | |
| const String _xcodeBuildCommand = 'xcodebuild'; | |
| const String _xcRunCommand = 'xcrun'; | |
| /// A utility class for interacting with the installed version of Xcode. | |
| class Xcode { | |
| /// Creates an instance that runs commands with the given [processRunner]. | |
| /// | |
| /// If [log] is true, commands run by this instance will long various status | |
| /// messages. | |
| Xcode({this.processRunner = const ProcessRunner(), this.log = false}); | |
| /// The [ProcessRunner] used to run commands. Overridable for testing. | |
| final ProcessRunner processRunner; | |
| /// Whether or not to log when running commands. | |
| final bool log; | |
| /// Runs an `xcodebuild` in [directory] with the given parameters. | |
| Future<int> runXcodeBuild( | |
| Directory exampleDirectory, | |
| String targetPlatform, { | |
| List<String> actions = const <String>['build'], | |
| required String workspace, | |
| required String scheme, | |
| String? configuration, | |
| List<String> extraFlags = const <String>[], | |
| required NativePlatform hostPlatform, | |
| }) async { | |
| final FileSystem fileSystem = exampleDirectory.fileSystem; | |
| String? resultBundlePath; | |
| final Directory? logsDirectory = ciLogsDirectory(hostPlatform, fileSystem); | |
| Directory? resultBundleTemp; | |
| try { | |
| if (logsDirectory != null) { | |
| resultBundleTemp = fileSystem.systemTempDirectory.createTempSync('flutter_xcresult.'); | |
| resultBundlePath = resultBundleTemp.childDirectory('result').path; | |
| } | |
| File? disabledSandboxEntitlementFile; | |
| if (actions.contains('test') && targetPlatform.toLowerCase() == 'macos') { | |
| disabledSandboxEntitlementFile = _createDisabledSandboxEntitlementFile( | |
| exampleDirectory.childDirectory(targetPlatform.toLowerCase()), | |
| configuration ?? 'Debug', | |
| ); | |
| } | |
| final args = <String>[ | |
| _xcodeBuildCommand, | |
| ...actions, | |
| ...<String>['-workspace', workspace], | |
| ...<String>['-scheme', scheme], | |
| if (resultBundlePath != null) ...<String>['-resultBundlePath', resultBundlePath], | |
| if (configuration != null) ...<String>['-configuration', configuration], | |
| ...extraFlags, | |
| if (disabledSandboxEntitlementFile != null) | |
| 'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}', | |
| ]; | |
| final completeTestCommand = '$_xcRunCommand ${args.join(' ')}'; | |
| if (log) { | |
| print(completeTestCommand); | |
| } | |
| final int resultExit = await processRunner.runAndStream( | |
| _xcRunCommand, | |
| args, | |
| workingDir: exampleDirectory, | |
| ); | |
| if (resultExit != 0 && resultBundleTemp != null) { | |
| final Directory xcresultBundle = resultBundleTemp.childDirectory('result.xcresult'); | |
| if (logsDirectory != null) { | |
| if (xcresultBundle.existsSync()) { | |
| // Zip the test results to the artifacts directory for upload. | |
| final File zipPath = logsDirectory.childFile( | |
| 'xcodebuild-${DateTime.now().toLocal().toIso8601String()}.zip', | |
| ); | |
| await processRunner.run('zip', <String>[ | |
| '-r', | |
| '-9', | |
| '-q', | |
| zipPath.path, | |
| xcresultBundle.basename, | |
| ], workingDir: resultBundleTemp); | |
| } else { | |
| print('xcresult bundle ${xcresultBundle.path} does not exist, skipping upload'); | |
| } | |
| } | |
| } | |
| return resultExit; | |
| } finally { | |
| resultBundleTemp?.deleteSync(recursive: true); | |
| } | |
| } | |
| /// Returns true if [project], which should be an .xcodeproj directory, | |
| /// contains a target called [target], false if it does not, and null if the | |
| /// check fails (e.g., if [project] is not an Xcode project). | |
| Future<bool?> projectHasTarget(Directory project, String target) async { | |
| final io.ProcessResult result = await processRunner.run(_xcRunCommand, <String>[ | |
| _xcodeBuildCommand, | |
| '-list', | |
| '-json', | |
| '-project', | |
| project.path, | |
| ]); | |
| if (result.exitCode != 0) { | |
| return null; | |
| } | |
| Map<String, dynamic>? projectInfo; | |
| try { | |
| projectInfo = | |
| (jsonDecode(result.stdout as String) as Map<String, dynamic>)['project'] | |
| as Map<String, dynamic>?; | |
| } on FormatException { | |
| return null; | |
| } | |
| if (projectInfo == null) { | |
| return null; | |
| } | |
| final List<String>? targets = (projectInfo['targets'] as List<dynamic>?)?.cast<String>(); | |
| return targets?.contains(target) ?? false; | |
| } | |
| /// Returns the newest available simulator (highest OS version, with ties | |
| /// broken in favor of newest device), if any. | |
| Future<String?> findBestAvailableIphoneSimulator() async { | |
| final findSimulatorsArguments = <String>[ | |
| 'simctl', | |
| 'list', | |
| 'devices', | |
| 'runtimes', | |
| 'available', | |
| '--json', | |
| ]; | |
| final findSimulatorCompleteCommand = '$_xcRunCommand ${findSimulatorsArguments.join(' ')}'; | |
| if (log) { | |
| print('Looking for available simulators...'); | |
| print(findSimulatorCompleteCommand); | |
| } | |
| final io.ProcessResult findSimulatorsResult = await processRunner.run( | |
| _xcRunCommand, | |
| findSimulatorsArguments, | |
| ); | |
| if (findSimulatorsResult.exitCode != 0) { | |
| if (log) { | |
| printError( | |
| 'Error occurred while running "$findSimulatorCompleteCommand":\n' | |
| '${findSimulatorsResult.stderr}', | |
| ); | |
| } | |
| return null; | |
| } | |
| final simulatorListJson = | |
| jsonDecode(findSimulatorsResult.stdout as String) as Map<String, dynamic>; | |
| final List<Map<String, dynamic>> runtimes = (simulatorListJson['runtimes'] as List<dynamic>) | |
| .cast<Map<String, dynamic>>(); | |
| final Map<String, Object> devices = (simulatorListJson['devices'] as Map<String, dynamic>) | |
| .cast<String, Object>(); | |
| if (runtimes.isEmpty || devices.isEmpty) { | |
| return null; | |
| } | |
| String? id; | |
| // Looking for runtimes, trying to find one with highest OS version. | |
| for (final Map<String, dynamic> rawRuntimeMap in runtimes.reversed) { | |
| final Map<String, Object> runtimeMap = rawRuntimeMap.cast<String, Object>(); | |
| if ((runtimeMap['name'] as String?)?.contains('iOS') != true) { | |
| continue; | |
| } | |
| final runtimeID = runtimeMap['identifier'] as String?; | |
| if (runtimeID == null) { | |
| continue; | |
| } | |
| final List<Map<String, dynamic>>? devicesForRuntime = (devices[runtimeID] as List<dynamic>?) | |
| ?.cast<Map<String, dynamic>>(); | |
| if (devicesForRuntime == null || devicesForRuntime.isEmpty) { | |
| continue; | |
| } | |
| // Looking for runtimes, trying to find latest version of device. | |
| for (final Map<String, dynamic> rawDevice in devicesForRuntime.reversed) { | |
| final Map<String, Object> device = rawDevice.cast<String, Object>(); | |
| id = device['udid'] as String?; | |
| if (id == null) { | |
| continue; | |
| } | |
| if (log) { | |
| print('device selected: $device'); | |
| } | |
| return id; | |
| } | |
| } | |
| return null; | |
| } | |
| /// 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(Directory macOSDirectory, String configuration) { | |
| final entitlementDefaultFileName = configuration == 'Release' ? 'Release' : 'DebugProfile'; | |
| final File entitlementFile = macOSDirectory | |
| .childDirectory('Runner') | |
| .childFile('$entitlementDefaultFileName.entitlements'); | |
| if (!entitlementFile.existsSync()) { | |
| print('Unable to find entitlements file at ${entitlementFile.path}'); | |
| return null; | |
| } | |
| final String originalEntitlementFileContents = entitlementFile.readAsStringSync(); | |
| final File disabledSandboxEntitlementFile = macOSDirectory.fileSystem.systemTempDirectory | |
| .createTempSync('flutter_disable_sandbox_entitlement.') | |
| .childFile('${entitlementDefaultFileName}WithDisabledSandboxing.entitlements'); | |
| disabledSandboxEntitlementFile.createSync(recursive: true); | |
| disabledSandboxEntitlementFile.writeAsStringSync( | |
| originalEntitlementFileContents.replaceAll( | |
| RegExp(r'<key>com\.apple\.security\.app-sandbox<\/key>[\S\s]*?<true\/>'), | |
| ''' | |
| <key>com.apple.security.app-sandbox</key> | |
| <false/>''', | |
| ), | |
| ); | |
| return disabledSandboxEntitlementFile; | |
| } | |
| } |