1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:convert';
6import 'dart:io';
7
8import 'package:path/path.dart' as path;
9
10import 'host_agent.dart';
11import 'utils.dart';
12
13typedef SimulatorFunction = Future<void> Function(String deviceId);
14
15Future<String> fileType(String pathToBinary) {
16 return eval('file', <String>[pathToBinary]);
17}
18
19Future<String?> minPhoneOSVersion(String pathToBinary) async {
20 final String loadCommands = await eval('otool', <String>['-l', '-arch', 'arm64', pathToBinary]);
21 if (!loadCommands.contains('LC_VERSION_MIN_IPHONEOS')) {
22 return null;
23 }
24
25 String? minVersion;
26 // Load command 7
27 // cmd LC_VERSION_MIN_IPHONEOS
28 // cmdsize 16
29 // version 9.0
30 // sdk 15.2
31 // ...
32 final List<String> lines = LineSplitter.split(loadCommands).toList();
33 lines.asMap().forEach((int index, String line) {
34 if (line.contains('LC_VERSION_MIN_IPHONEOS') && lines.length - index - 1 > 3) {
35 final String versionLine = lines.skip(index - 1).take(4).last;
36 final RegExp versionRegex = RegExp(r'\s*version\s*(\S*)');
37 minVersion = versionRegex.firstMatch(versionLine)?.group(1);
38 }
39 });
40 return minVersion;
41}
42
43/// Creates and boots a new simulator, passes the new simulator's identifier to
44/// `testFunction`.
45///
46/// Remember to call removeIOSSimulator in the test teardown.
47Future<void> testWithNewIOSSimulator(
48 String deviceName,
49 SimulatorFunction testFunction, {
50 String deviceTypeId = 'com.apple.CoreSimulator.SimDeviceType.iPhone-11',
51}) async {
52 final String availableRuntimes = await eval('xcrun', <String>[
53 'simctl',
54 'list',
55 'runtimes',
56 ], workingDirectory: flutterDirectory.path);
57
58 final String runtimesForSelectedXcode = await eval('xcrun', <String>[
59 'simctl',
60 'runtime',
61 'match',
62 'list',
63 '--json',
64 ], workingDirectory: flutterDirectory.path);
65
66 // First check for userOverriddenBuild, which may be set in CI by mac_toolchain.
67 // Next, get the preferred runtime build for the selected Xcode version. Preferred
68 // means the runtime was either bundled with Xcode, exactly matched your SDK
69 // version, or it's indicated a better match for your SDK.
70 final Map<String, Object?> decodeResult =
71 json.decode(runtimesForSelectedXcode) as Map<String, Object?>;
72 final String? iosKey = decodeResult.keys
73 .where((String key) => key.contains('iphoneos'))
74 .firstOrNull;
75 final String? runtimeBuildForSelectedXcode = switch (decodeResult[iosKey]) {
76 {'userOverriddenBuild': final String build} => build,
77 {'preferredBuild': final String build} => build,
78 _ => null,
79 };
80
81 String? iOSSimRuntime;
82
83 final RegExp iOSRuntimePattern = RegExp(r'iOS .*\) - (.*)');
84
85 // [availableRuntimes] may include runtime versions greater than the selected
86 // Xcode's greatest supported version. Use [runtimeBuildForSelectedXcode] when
87 // possible to pick which runtime to use.
88 // For example, iOS 17 (released with Xcode 15) may be available even if the
89 // selected Xcode version is 14.
90 for (final String runtime in LineSplitter.split(availableRuntimes)) {
91 if (runtimeBuildForSelectedXcode != null && !runtime.contains(runtimeBuildForSelectedXcode)) {
92 continue;
93 }
94 // These seem to be in order, so allow matching multiple lines so it grabs
95 // the last (hopefully latest) one.
96 final RegExpMatch? iOSRuntimeMatch = iOSRuntimePattern.firstMatch(runtime);
97 if (iOSRuntimeMatch != null) {
98 iOSSimRuntime = iOSRuntimeMatch.group(1)!.trim();
99 continue;
100 }
101 }
102 if (iOSSimRuntime == null) {
103 if (runtimeBuildForSelectedXcode != null) {
104 throw 'iOS simulator runtime $runtimeBuildForSelectedXcode not found. Available runtimes:\n$availableRuntimes';
105 } else {
106 throw 'No iOS simulator runtime found. Available runtimes:\n$availableRuntimes';
107 }
108 }
109
110 final String deviceId = await eval('xcrun', <String>[
111 'simctl',
112 'create',
113 deviceName,
114 deviceTypeId,
115 iOSSimRuntime,
116 ], workingDirectory: flutterDirectory.path);
117 await eval('xcrun', <String>[
118 'simctl',
119 'boot',
120 deviceId,
121 ], workingDirectory: flutterDirectory.path);
122
123 await testFunction(deviceId);
124}
125
126/// Shuts down and deletes simulator with deviceId.
127Future<void> removeIOSSimulator(String? deviceId) async {
128 if (deviceId != null && deviceId != '') {
129 await eval(
130 'xcrun',
131 <String>['simctl', 'shutdown', deviceId],
132 canFail: true,
133 workingDirectory: flutterDirectory.path,
134 );
135 await eval(
136 'xcrun',
137 <String>['simctl', 'delete', deviceId],
138 canFail: true,
139 workingDirectory: flutterDirectory.path,
140 );
141 }
142}
143
144Future<bool> runXcodeTests({
145 required String platformDirectory,
146 required String destination,
147 required String testName,
148 List<String> actions = const <String>['test'],
149 String configuration = 'Release',
150 List<String> extraOptions = const <String>[],
151 String scheme = 'Runner',
152 bool skipCodesign = false,
153}) {
154 return runXcodeBuild(
155 platformDirectory: platformDirectory,
156 destination: destination,
157 testName: testName,
158 actions: actions,
159 configuration: configuration,
160 extraOptions: extraOptions,
161 scheme: scheme,
162 skipCodesign: skipCodesign,
163 );
164}
165
166Future<bool> runXcodeBuild({
167 required String platformDirectory,
168 required String destination,
169 required String testName,
170 List<String> actions = const <String>['build'],
171 String configuration = 'Release',
172 List<String> extraOptions = const <String>[],
173 String scheme = 'Runner',
174 bool skipCodesign = false,
175}) async {
176 final Map<String, String> environment = Platform.environment;
177 String? developmentTeam;
178 String? codeSignStyle;
179 String? provisioningProfile;
180 if (!skipCodesign) {
181 // If not running on CI, inject the Flutter team code signing properties.
182 developmentTeam = environment['FLUTTER_XCODE_DEVELOPMENT_TEAM'] ?? 'S8QB4VV633';
183 codeSignStyle = environment['FLUTTER_XCODE_CODE_SIGN_STYLE'];
184 provisioningProfile = environment['FLUTTER_XCODE_PROVISIONING_PROFILE_SPECIFIER'];
185 }
186 File? disabledSandboxEntitlementFile;
187 if (platformDirectory.endsWith('macos')) {
188 disabledSandboxEntitlementFile = _createDisabledSandboxEntitlementFile(
189 platformDirectory,
190 configuration,
191 );
192 }
193 final String resultBundleTemp = Directory.systemTemp.createTempSync('flutter_xcresult.').path;
194 final String resultBundlePath = path.join(resultBundleTemp, 'result');
195 final int testResultExit = await exec(
196 'xcodebuild',
197 <String>[
198 '-workspace',
199 'Runner.xcworkspace',
200 '-scheme',
201 scheme,
202 '-configuration',
203 configuration,
204 '-destination',
205 destination,
206 '-resultBundlePath',
207 resultBundlePath,
208 ...actions,
209 ...extraOptions,
210 'COMPILER_INDEX_STORE_ENABLE=NO',
211 if (developmentTeam != null) 'DEVELOPMENT_TEAM=$developmentTeam',
212 if (codeSignStyle != null) 'CODE_SIGN_STYLE=$codeSignStyle',
213 if (provisioningProfile != null) 'PROVISIONING_PROFILE_SPECIFIER=$provisioningProfile',
214 if (disabledSandboxEntitlementFile != null)
215 'CODE_SIGN_ENTITLEMENTS=${disabledSandboxEntitlementFile.path}',
216 ],
217 workingDirectory: platformDirectory,
218 canFail: true,
219 );
220
221 if (testResultExit != 0) {
222 final Directory? dumpDirectory = hostAgent.dumpDirectory;
223 final Directory xcresultBundle = Directory(path.join(resultBundleTemp, 'result.xcresult'));
224 if (dumpDirectory != null) {
225 if (xcresultBundle.existsSync()) {
226 // Zip the test results to the artifacts directory for upload.
227 final String zipPath = path.join(
228 dumpDirectory.path,
229 '$testName-${DateTime.now().toLocal().toIso8601String()}.zip',
230 );
231 await exec(
232 'zip',
233 <String>['-r', '-9', '-q', zipPath, path.basename(xcresultBundle.path)],
234 workingDirectory: resultBundleTemp,
235 canFail: true, // Best effort to get the logs.
236 );
237 } else {
238 print('xcresult bundle ${xcresultBundle.path} does not exist, skipping upload');
239 }
240 }
241 return false;
242 }
243 return true;
244}
245
246/// Finds and copies macOS entitlements file. In the copy, disables sandboxing.
247/// If entitlements file is not found, returns null.
248///
249/// As of macOS 14, testing a macOS sandbox app may prompt the user to grant
250/// access to the app. To workaround this in CI, we create and use a entitlements
251/// file with sandboxing disabled. See
252/// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox.
253File? _createDisabledSandboxEntitlementFile(String platformDirectory, String configuration) {
254 String entitlementDefaultFileName;
255 if (configuration == 'Release') {
256 entitlementDefaultFileName = 'Release';
257 } else {
258 entitlementDefaultFileName = 'DebugProfile';
259 }
260
261 final String entitlementFilePath = path.join(
262 platformDirectory,
263 'Runner',
264 '$entitlementDefaultFileName.entitlements',
265 );
266 final File entitlementFile = File(entitlementFilePath);
267
268 if (!entitlementFile.existsSync()) {
269 print('Unable to find entitlements file at ${entitlementFile.path}');
270 return null;
271 }
272
273 final String originalEntitlementFileContents = entitlementFile.readAsStringSync();
274 final String tempEntitlementPath = Directory.systemTemp
275 .createTempSync('flutter_disable_sandbox_entitlement.')
276 .path;
277 final File disabledSandboxEntitlementFile = File(
278 path.join(
279 tempEntitlementPath,
280 '${entitlementDefaultFileName}WithDisabledSandboxing.entitlements',
281 ),
282 );
283 disabledSandboxEntitlementFile.createSync(recursive: true);
284 disabledSandboxEntitlementFile.writeAsStringSync(
285 originalEntitlementFileContents.replaceAll(
286 RegExp(r'<key>com\.apple\.security\.app-sandbox<\/key>[\S\s]*?<true\/>'),
287 '''
288<key>com.apple.security.app-sandbox</key>
289 <false/>''',
290 ),
291 );
292
293 return disabledSandboxEntitlementFile;
294}
295
296/// Returns global (external) symbol table entries, delimited by new lines.
297Future<String> dumpSymbolTable(String filePath) {
298 return eval('nm', <String>['--extern-only', '--just-symbol-name', filePath, '-arch', 'arm64']);
299}
300