// Copyright 2014 The Flutter Authors. All rights reserved. // 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'; import 'package:path/path.dart' as path; import 'host_agent.dart'; import 'utils.dart'; typedef SimulatorFunction = Future Function(String deviceId); Future fileType(String pathToBinary) { return eval('file', [pathToBinary]); } Future minPhoneOSVersion(String pathToBinary) async { final String loadCommands = await eval('otool', ['-l', '-arch', 'arm64', pathToBinary]); if (!loadCommands.contains('LC_VERSION_MIN_IPHONEOS')) { return null; } String? minVersion; // Load command 7 // cmd LC_VERSION_MIN_IPHONEOS // cmdsize 16 // version 9.0 // sdk 15.2 // ... final List lines = LineSplitter.split(loadCommands).toList(); lines.asMap().forEach((int index, String line) { if (line.contains('LC_VERSION_MIN_IPHONEOS') && lines.length - index - 1 > 3) { final String versionLine = lines.skip(index - 1).take(4).last; final RegExp versionRegex = RegExp(r'\s*version\s*(\S*)'); minVersion = versionRegex.firstMatch(versionLine)?.group(1); } }); return minVersion; } /// Creates and boots a new simulator, passes the new simulator's identifier to /// `testFunction`. /// /// Remember to call removeIOSSimulator in the test teardown. Future testWithNewIOSSimulator( String deviceName, SimulatorFunction testFunction, { String deviceTypeId = 'com.apple.CoreSimulator.SimDeviceType.iPhone-11', }) async { final String availableRuntimes = await eval('xcrun', [ 'simctl', 'list', 'runtimes', ], workingDirectory: flutterDirectory.path); final String runtimesForSelectedXcode = await eval('xcrun', [ 'simctl', 'runtime', 'match', 'list', '--json', ], workingDirectory: flutterDirectory.path); // First check for userOverriddenBuild, which may be set in CI by mac_toolchain. // Next, get the preferred runtime build for the selected Xcode version. Preferred // means the runtime was either bundled with Xcode, exactly matched your SDK // version, or it's indicated a better match for your SDK. final Map decodeResult = json.decode(runtimesForSelectedXcode) as Map; final String? iosKey = decodeResult.keys.where((String key) => key.contains('iphoneos')).firstOrNull; final String? runtimeBuildForSelectedXcode = switch (decodeResult[iosKey]) { {'userOverriddenBuild': final String build} => build, {'preferredBuild': final String build} => build, _ => null, }; String? iOSSimRuntime; final RegExp iOSRuntimePattern = RegExp(r'iOS .*\) - (.*)'); // [availableRuntimes] may include runtime versions greater than the selected // Xcode's greatest supported version. Use [runtimeBuildForSelectedXcode] when // possible to pick which runtime to use. // For example, iOS 17 (released with Xcode 15) may be available even if the // selected Xcode version is 14. for (final String runtime in LineSplitter.split(availableRuntimes)) { if (runtimeBuildForSelectedXcode != null && !runtime.contains(runtimeBuildForSelectedXcode)) { continue; } // These seem to be in order, so allow matching multiple lines so it grabs // the last (hopefully latest) one. final RegExpMatch? iOSRuntimeMatch = iOSRuntimePattern.firstMatch(runtime); if (iOSRuntimeMatch != null) { iOSSimRuntime = iOSRuntimeMatch.group(1)!.trim(); continue; } } if (iOSSimRuntime == null) { if (runtimeBuildForSelectedXcode != null) { throw 'iOS simulator runtime $runtimeBuildForSelectedXcode not found. Available runtimes:\n$availableRuntimes'; } else { throw 'No iOS simulator runtime found. Available runtimes:\n$availableRuntimes'; } } final String deviceId = await eval('xcrun', [ 'simctl', 'create', deviceName, deviceTypeId, iOSSimRuntime, ], workingDirectory: flutterDirectory.path); await eval('xcrun', [ 'simctl', 'boot', deviceId, ], workingDirectory: flutterDirectory.path); await testFunction(deviceId); } /// Shuts down and deletes simulator with deviceId. Future removeIOSSimulator(String? deviceId) async { if (deviceId != null && deviceId != '') { await eval( 'xcrun', ['simctl', 'shutdown', deviceId], canFail: true, workingDirectory: flutterDirectory.path, ); await eval( 'xcrun', ['simctl', 'delete', deviceId], canFail: true, workingDirectory: flutterDirectory.path, ); } } Future runXcodeTests({ required String platformDirectory, required String destination, required String testName, List actions = const ['test'], String configuration = 'Release', List extraOptions = const [], String scheme = 'Runner', bool skipCodesign = false, }) { return runXcodeBuild( platformDirectory: platformDirectory, destination: destination, testName: testName, actions: actions, configuration: configuration, extraOptions: extraOptions, scheme: scheme, skipCodesign: skipCodesign, ); } Future runXcodeBuild({ required String platformDirectory, required String destination, required String testName, List actions = const ['build'], String configuration = 'Release', List extraOptions = const [], String scheme = 'Runner', bool skipCodesign = false, }) async { final Map environment = Platform.environment; String? developmentTeam; String? codeSignStyle; String? provisioningProfile; if (!skipCodesign) { // If not running on CI, inject the Flutter team code signing properties. developmentTeam = environment['FLUTTER_XCODE_DEVELOPMENT_TEAM'] ?? 'S8QB4VV633'; 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( 'xcodebuild', [ '-workspace', 'Runner.xcworkspace', '-scheme', scheme, '-configuration', configuration, '-destination', destination, '-resultBundlePath', resultBundlePath, ...actions, ...extraOptions, 'COMPILER_INDEX_STORE_ENABLE=NO', if (developmentTeam != null) 'DEVELOPMENT_TEAM=$developmentTeam', if (codeSignStyle != null) 'CODE_SIGN_STYLE=$codeSignStyle', if (provisioningProfile != null) 'PROVISIONING_PROFILE_SPECIFIER=$provisioningProfile', if (disabledSandboxEntitlementFile != null) 'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}', ], workingDirectory: platformDirectory, canFail: true, ); if (testResultExit != 0) { final Directory? dumpDirectory = hostAgent.dumpDirectory; final Directory xcresultBundle = Directory(path.join(resultBundleTemp, 'result.xcresult')); if (dumpDirectory != null) { if (xcresultBundle.existsSync()) { // Zip the test results to the artifacts directory for upload. final String zipPath = path.join( dumpDirectory.path, '$testName-${DateTime.now().toLocal().toIso8601String()}.zip', ); await exec( 'zip', ['-r', '-9', '-q', zipPath, path.basename(xcresultBundle.path)], workingDirectory: resultBundleTemp, canFail: true, // Best effort to get the logs. ); } else { print('xcresult bundle ${xcresultBundle.path} does not exist, skipping upload'); } } return false; } 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; } /// Returns global (external) symbol table entries, delimited by new lines. Future dumpSymbolTable(String filePath) { return eval('nm', ['--extern-only', '--just-symbol-name', filePath, '-arch', 'arm64']); }