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 | |
5 | import 'dart:convert'; |
6 | import 'dart:io'; |
7 | |
8 | import 'package:path/path.dart' as path; |
9 | |
10 | import 'host_agent.dart'; |
11 | import 'utils.dart'; |
12 | |
13 | typedef SimulatorFunction = Future<void> Function(String deviceId); |
14 | |
15 | Future<String> fileType(String pathToBinary) { |
16 | return eval('file' , <String>[pathToBinary]); |
17 | } |
18 | |
19 | Future<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. |
47 | Future<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. |
127 | Future<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 | |
144 | Future<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 | |
166 | Future<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. |
253 | File? _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. |
297 | Future<String> dumpSymbolTable(String filePath) { |
298 | return eval('nm' , <String>['--extern-only' , '--just-symbol-name' , filePath, '-arch' , 'arm64' ]); |
299 | } |
300 | |