| // 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 Platform 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; |
| } |
| } |