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:typed_data'; |
6 | |
7 | import 'package:crypto/crypto.dart'; |
8 | import 'package:meta/meta.dart'; |
9 | import 'package:unified_analytics/unified_analytics.dart'; |
10 | |
11 | import '../base/analyze_size.dart'; |
12 | import '../base/common.dart'; |
13 | import '../base/error_handling_io.dart'; |
14 | import '../base/file_system.dart'; |
15 | import '../base/logger.dart'; |
16 | import '../base/process.dart'; |
17 | import '../base/terminal.dart'; |
18 | import '../base/utils.dart'; |
19 | import '../base/version.dart'; |
20 | import '../build_info.dart'; |
21 | import '../convert.dart'; |
22 | import '../doctor_validator.dart'; |
23 | import '../globals.dart' as globals; |
24 | import '../ios/application_package.dart'; |
25 | import '../ios/mac.dart'; |
26 | import '../ios/plist_parser.dart'; |
27 | import '../project.dart'; |
28 | import '../runner/flutter_command.dart'; |
29 | import 'build.dart'; |
30 | |
31 | /// Builds an .app for an iOS app to be used for local testing on an iOS device |
32 | /// or simulator. Can only be run on a macOS host. |
33 | class BuildIOSCommand extends _BuildIOSSubCommand { |
34 | BuildIOSCommand({required super.logger, required bool verboseHelp}) |
35 | : super(verboseHelp: verboseHelp) { |
36 | addPublishPort(verboseHelp: verboseHelp); |
37 | argParser |
38 | ..addFlag( |
39 | 'config-only', |
40 | help: |
41 | 'Update the project configuration without performing a build. ' |
42 | 'This can be used in CI/CD process that create an archive to avoid ' |
43 | 'performing duplicate work.', |
44 | ) |
45 | ..addFlag( |
46 | 'simulator', |
47 | help: |
48 | 'Build for the iOS simulator instead of the device. This changes ' |
49 | 'the default build mode to debug if otherwise unspecified.', |
50 | ); |
51 | } |
52 | |
53 | @override |
54 | final String name = 'ios'; |
55 | |
56 | @override |
57 | final String description = 'Build an iOS application bundle.'; |
58 | |
59 | @override |
60 | final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.build; |
61 | |
62 | @override |
63 | EnvironmentType get environmentType => |
64 | boolArg('simulator') ? EnvironmentType.simulator : EnvironmentType.physical; |
65 | |
66 | @override |
67 | bool get configOnly => boolArg('config-only'); |
68 | |
69 | @override |
70 | Directory _outputAppDirectory(String xcodeResultOutput) => |
71 | globals.fs.directory(xcodeResultOutput).parent; |
72 | } |
73 | |
74 | /// The key that uniquely identifies an image file in an image asset. |
75 | /// It consists of (idiom, scale, size?), where size is present for app icon |
76 | /// asset, and null for launch image asset. |
77 | @immutable |
78 | class _ImageAssetFileKey { |
79 | const _ImageAssetFileKey(this.idiom, this.scale, this.size); |
80 | |
81 | /// The idiom (iphone or ipad). |
82 | final String idiom; |
83 | |
84 | /// The scale factor (e.g. 2). |
85 | final int scale; |
86 | |
87 | /// The logical size in point (e.g. 83.5). |
88 | /// Size is present for app icon, and null for launch image. |
89 | final double? size; |
90 | |
91 | @override |
92 | int get hashCode => Object.hash(idiom, scale, size); |
93 | |
94 | @override |
95 | bool operator ==(Object other) => |
96 | other is _ImageAssetFileKey && |
97 | other.idiom == idiom && |
98 | other.scale == scale && |
99 | other.size == size; |
100 | |
101 | /// The pixel size based on logical size and scale. |
102 | int? get pixelSize => size == null ? null : (size! * scale).toInt(); // pixel size must be an int. |
103 | } |
104 | |
105 | /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for |
106 | /// App Store submission. |
107 | /// |
108 | /// Can only be run on a macOS host. |
109 | class BuildIOSArchiveCommand extends _BuildIOSSubCommand { |
110 | BuildIOSArchiveCommand({required super.logger, required super.verboseHelp}) { |
111 | argParser.addOption( |
112 | 'export-method', |
113 | defaultsTo: 'app-store', |
114 | allowed: <String>['app-store', 'ad-hoc', 'development', 'enterprise'], |
115 | help: 'Specify how the IPA will be distributed.', |
116 | allowedHelp: <String, String>{ |
117 | 'app-store': 'Upload to the App Store.', |
118 | 'ad-hoc': |
119 | 'Test on designated devices that do not need to be registered with the Apple developer account. ' |
120 | 'Requires a distribution certificate.', |
121 | 'development': |
122 | 'Test only on development devices registered with the Apple developer account.', |
123 | 'enterprise': 'Distribute an app registered with the Apple Developer Enterprise Program.', |
124 | }, |
125 | ); |
126 | argParser.addOption( |
127 | 'export-options-plist', |
128 | valueHelp: 'ExportOptions.plist', |
129 | help: |
130 | 'Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.', |
131 | ); |
132 | } |
133 | |
134 | @override |
135 | final String name = 'ipa'; |
136 | |
137 | @override |
138 | final List<String> aliases = <String>['xcarchive']; |
139 | |
140 | @override |
141 | final String description = 'Build an iOS archive bundle and IPA for distribution.'; |
142 | |
143 | @override |
144 | final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.archive; |
145 | |
146 | @override |
147 | final EnvironmentType environmentType = EnvironmentType.physical; |
148 | |
149 | @override |
150 | final bool configOnly = false; |
151 | |
152 | String? get exportOptionsPlist => stringArg('export-options-plist'); |
153 | |
154 | @override |
155 | Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs |
156 | .directory(xcodeResultOutput) |
157 | .childDirectory('Products') |
158 | .childDirectory('Applications'); |
159 | |
160 | @override |
161 | Future<void> validateCommand() async { |
162 | final String? exportOptions = exportOptionsPlist; |
163 | if (exportOptions != null) { |
164 | if (argResults?.wasParsed('export-method') ?? false) { |
165 | throwToolExit( |
166 | '"--export-options-plist" is not compatible with "--export-method". Either use "--export-options-plist" and ' |
167 | 'a plist describing how the IPA should be exported by Xcode, or use "--export-method" to create a new plist.\n' |
168 | 'See "xcodebuild -h" for available exportOptionsPlist keys.', |
169 | ); |
170 | } |
171 | final FileSystemEntityType type = globals.fs.typeSync(exportOptions); |
172 | if (type == FileSystemEntityType.notFound) { |
173 | throwToolExit('"$exportOptions " property list does not exist.'); |
174 | } else if (type != FileSystemEntityType.file) { |
175 | throwToolExit('"$exportOptions " is not a file. See "xcodebuild -h" for available keys.'); |
176 | } |
177 | } |
178 | return super.validateCommand(); |
179 | } |
180 | |
181 | // A helper function to parse Contents.json of an image asset into a map, |
182 | // with the key to be _ImageAssetFileKey, and value to be the image file name. |
183 | // Some assets have size (e.g. app icon) and others do not (e.g. launch image). |
184 | Map<_ImageAssetFileKey, String> _parseImageAssetContentsJson( |
185 | String contentsJsonDirName, { |
186 | required bool requiresSize, |
187 | }) { |
188 | final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); |
189 | if (!contentsJsonDirectory.existsSync()) { |
190 | return <_ImageAssetFileKey, String>{}; |
191 | } |
192 | final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json'); |
193 | final Map<String, dynamic> contents = |
194 | json.decode(contentsJsonFile.readAsStringSync()) as Map<String, dynamic>? ?? |
195 | <String, dynamic>{}; |
196 | final List<dynamic> images = contents['images'] as List<dynamic>? ?? <dynamic>[]; |
197 | final Map<String, dynamic> info = |
198 | contents['info'] as Map<String, dynamic>? ?? <String, dynamic>{}; |
199 | if ((info['version'] as int?) != 1) { |
200 | // Skips validation for unknown format. |
201 | return <_ImageAssetFileKey, String>{}; |
202 | } |
203 | |
204 | final Map<_ImageAssetFileKey, String> iconInfo = <_ImageAssetFileKey, String>{}; |
205 | for (final dynamic image in images) { |
206 | final Map<String, dynamic> imageMap = image as Map<String, dynamic>; |
207 | final String? idiom = imageMap['idiom'] as String?; |
208 | final String? size = imageMap['size'] as String?; |
209 | final String? scale = imageMap['scale'] as String?; |
210 | final String? fileName = imageMap['filename'] as String?; |
211 | |
212 | // requiresSize must match the actual presence of size in json. |
213 | if (requiresSize != (size != null) || idiom == null || scale == null || fileName == null) { |
214 | continue; |
215 | } |
216 | |
217 | final double? parsedSize; |
218 | if (size != null) { |
219 | // for example, "64x64". Parse the width since it is a square. |
220 | final Iterable<double> parsedSizes = |
221 | size.split('x').map((String element) => double.tryParse(element)).whereType<double>(); |
222 | if (parsedSizes.isEmpty) { |
223 | continue; |
224 | } |
225 | parsedSize = parsedSizes.first; |
226 | } else { |
227 | parsedSize = null; |
228 | } |
229 | |
230 | // for example, "3x". |
231 | final Iterable<int> parsedScales = |
232 | scale.split('x').map((String element) => int.tryParse(element)).whereType<int>(); |
233 | if (parsedScales.isEmpty) { |
234 | continue; |
235 | } |
236 | final int parsedScale = parsedScales.first; |
237 | iconInfo[_ImageAssetFileKey(idiom, parsedScale, parsedSize)] = fileName; |
238 | } |
239 | return iconInfo; |
240 | } |
241 | |
242 | // A helper function to check if an image asset is still using template files. |
243 | bool _isAssetStillUsingTemplateFiles({ |
244 | required Map<_ImageAssetFileKey, String> templateImageInfoMap, |
245 | required Map<_ImageAssetFileKey, String> projectImageInfoMap, |
246 | required String templateImageDirName, |
247 | required String projectImageDirName, |
248 | }) { |
249 | return projectImageInfoMap.entries.any((MapEntry<_ImageAssetFileKey, String> entry) { |
250 | final String projectFileName = entry.value; |
251 | final String? templateFileName = templateImageInfoMap[entry.key]; |
252 | if (templateFileName == null) { |
253 | return false; |
254 | } |
255 | final File projectFile = globals.fs.file( |
256 | globals.fs.path.join(projectImageDirName, projectFileName), |
257 | ); |
258 | final File templateFile = globals.fs.file( |
259 | globals.fs.path.join(templateImageDirName, templateFileName), |
260 | ); |
261 | |
262 | return projectFile.existsSync() && |
263 | templateFile.existsSync() && |
264 | md5.convert(projectFile.readAsBytesSync()) == md5.convert(templateFile.readAsBytesSync()); |
265 | }); |
266 | } |
267 | |
268 | // A helper function to return a list of image files in an image asset with |
269 | // wrong sizes (as specified in its Contents.json file). |
270 | List<String> _imageFilesWithWrongSize({ |
271 | required Map<_ImageAssetFileKey, String> imageInfoMap, |
272 | required String imageDirName, |
273 | }) { |
274 | return imageInfoMap.entries |
275 | .where((MapEntry<_ImageAssetFileKey, String> entry) { |
276 | final String fileName = entry.value; |
277 | final File imageFile = globals.fs.file(globals.fs.path.join(imageDirName, fileName)); |
278 | if (!imageFile.existsSync()) { |
279 | return false; |
280 | } |
281 | // validate image size is correct. |
282 | // PNG file's width is at byte [16, 20), and height is at byte [20, 24), in big endian format. |
283 | // Based on https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_format |
284 | final ByteData imageData = imageFile.readAsBytesSync().buffer.asByteData(); |
285 | if (imageData.lengthInBytes < 24) { |
286 | return false; |
287 | } |
288 | final int width = imageData.getInt32(16); |
289 | final int height = imageData.getInt32(20); |
290 | // The size must not be null. |
291 | final int expectedSize = entry.key.pixelSize!; |
292 | return width != expectedSize || height != expectedSize; |
293 | }) |
294 | .map((MapEntry<_ImageAssetFileKey, String> entry) => entry.value) |
295 | .toList(); |
296 | } |
297 | |
298 | ValidationResult? _createValidationResult(String title, List<ValidationMessage> messages) { |
299 | if (messages.isEmpty) { |
300 | return null; |
301 | } |
302 | final bool anyInvalid = messages.any( |
303 | (ValidationMessage message) => message.type != ValidationMessageType.information, |
304 | ); |
305 | return ValidationResult( |
306 | anyInvalid ? ValidationType.partial : ValidationType.success, |
307 | messages, |
308 | statusInfo: title, |
309 | ); |
310 | } |
311 | |
312 | ValidationMessage _createValidationMessage({required bool isValid, required String message}) { |
313 | // Use "information" type for valid message, and "hint" type for invalid message. |
314 | return isValid ? ValidationMessage(message) : ValidationMessage.hint(message); |
315 | } |
316 | |
317 | Future<List<ValidationMessage>> _validateIconAssetsAfterArchive() async { |
318 | final BuildableIOSApp app = await buildableIOSApp; |
319 | |
320 | final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( |
321 | app.templateAppIconDirNameForContentsJson, |
322 | requiresSize: true, |
323 | ); |
324 | final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( |
325 | app.projectAppIconDirName, |
326 | requiresSize: true, |
327 | ); |
328 | |
329 | final List<ValidationMessage> validationMessages = <ValidationMessage>[]; |
330 | |
331 | final bool usesTemplate = _isAssetStillUsingTemplateFiles( |
332 | templateImageInfoMap: templateInfoMap, |
333 | projectImageInfoMap: projectInfoMap, |
334 | templateImageDirName: await app.templateAppIconDirNameForImages, |
335 | projectImageDirName: app.projectAppIconDirName, |
336 | ); |
337 | |
338 | if (usesTemplate) { |
339 | validationMessages.add( |
340 | _createValidationMessage( |
341 | isValid: false, |
342 | message: 'App icon is set to the default placeholder icon. Replace with unique icons.', |
343 | ), |
344 | ); |
345 | } |
346 | |
347 | final List<String> filesWithWrongSize = _imageFilesWithWrongSize( |
348 | imageInfoMap: projectInfoMap, |
349 | imageDirName: app.projectAppIconDirName, |
350 | ); |
351 | |
352 | if (filesWithWrongSize.isNotEmpty) { |
353 | validationMessages.add( |
354 | _createValidationMessage( |
355 | isValid: false, |
356 | message: 'App icon is using the incorrect size (e.g.${filesWithWrongSize.first} ).', |
357 | ), |
358 | ); |
359 | } |
360 | return validationMessages; |
361 | } |
362 | |
363 | Future<List<ValidationMessage>> _validateLaunchImageAssetsAfterArchive() async { |
364 | final BuildableIOSApp app = await buildableIOSApp; |
365 | |
366 | final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( |
367 | app.templateLaunchImageDirNameForContentsJson, |
368 | requiresSize: false, |
369 | ); |
370 | final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( |
371 | app.projectLaunchImageDirName, |
372 | requiresSize: false, |
373 | ); |
374 | |
375 | final List<ValidationMessage> validationMessages = <ValidationMessage>[]; |
376 | |
377 | final bool usesTemplate = _isAssetStillUsingTemplateFiles( |
378 | templateImageInfoMap: templateInfoMap, |
379 | projectImageInfoMap: projectInfoMap, |
380 | templateImageDirName: await app.templateLaunchImageDirNameForImages, |
381 | projectImageDirName: app.projectLaunchImageDirName, |
382 | ); |
383 | |
384 | if (usesTemplate) { |
385 | validationMessages.add( |
386 | _createValidationMessage( |
387 | isValid: false, |
388 | message: |
389 | 'Launch image is set to the default placeholder icon. Replace with unique launch image.', |
390 | ), |
391 | ); |
392 | } |
393 | |
394 | return validationMessages; |
395 | } |
396 | |
397 | Future<List<ValidationMessage>> _validateXcodeBuildSettingsAfterArchive() async { |
398 | final BuildableIOSApp app = await buildableIOSApp; |
399 | |
400 | final String plistPath = app.builtInfoPlistPathAfterArchive; |
401 | |
402 | if (!globals.fs.file(plistPath).existsSync()) { |
403 | globals.printError('Invalid iOS archive. Does not contain Info.plist.'); |
404 | return <ValidationMessage>[]; |
405 | } |
406 | |
407 | final Map<String, String?> xcodeProjectSettingsMap = <String, String?>{}; |
408 | |
409 | xcodeProjectSettingsMap['Version Number'] = globals.plistParser.getValueFromFile<String>( |
410 | plistPath, |
411 | PlistParser.kCFBundleShortVersionStringKey, |
412 | ); |
413 | xcodeProjectSettingsMap['Build Number'] = globals.plistParser.getValueFromFile<String>( |
414 | plistPath, |
415 | PlistParser.kCFBundleVersionKey, |
416 | ); |
417 | xcodeProjectSettingsMap['Display Name'] = |
418 | globals.plistParser.getValueFromFile<String>( |
419 | plistPath, |
420 | PlistParser.kCFBundleDisplayNameKey, |
421 | ) ?? |
422 | globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleNameKey); |
423 | xcodeProjectSettingsMap['Deployment Target'] = globals.plistParser.getValueFromFile<String>( |
424 | plistPath, |
425 | PlistParser.kMinimumOSVersionKey, |
426 | ); |
427 | xcodeProjectSettingsMap['Bundle Identifier'] = globals.plistParser.getValueFromFile<String>( |
428 | plistPath, |
429 | PlistParser.kCFBundleIdentifierKey, |
430 | ); |
431 | |
432 | final List<ValidationMessage> validationMessages = |
433 | xcodeProjectSettingsMap.entries.map((MapEntry<String, String?> entry) { |
434 | final String title = entry.key; |
435 | final String? info = entry.value; |
436 | return _createValidationMessage( |
437 | isValid: info != null, |
438 | message: '$title :${info ?? "Missing"} ', |
439 | ); |
440 | }).toList(); |
441 | |
442 | final bool hasMissingSettings = xcodeProjectSettingsMap.values.any( |
443 | (String? element) => element == null, |
444 | ); |
445 | if (hasMissingSettings) { |
446 | validationMessages.add( |
447 | _createValidationMessage( |
448 | isValid: false, |
449 | message: 'You must set up the missing app settings.', |
450 | ), |
451 | ); |
452 | } |
453 | |
454 | final bool usesDefaultBundleIdentifier = |
455 | xcodeProjectSettingsMap['Bundle Identifier']?.startsWith( 'com.example') ?? false; |
456 | if (usesDefaultBundleIdentifier) { |
457 | validationMessages.add( |
458 | _createValidationMessage( |
459 | isValid: false, |
460 | message: 'Your application still contains the default "com.example" bundle identifier.', |
461 | ), |
462 | ); |
463 | } |
464 | |
465 | return validationMessages; |
466 | } |
467 | |
468 | @override |
469 | Future<FlutterCommandResult> runCommand() async { |
470 | final BuildInfo buildInfo = await cachedBuildInfo; |
471 | final FlutterCommandResult xcarchiveResult = await super.runCommand(); |
472 | |
473 | final List<ValidationResult?> validationResults = <ValidationResult?>[]; |
474 | validationResults.add( |
475 | _createValidationResult( |
476 | 'App Settings Validation', |
477 | await _validateXcodeBuildSettingsAfterArchive(), |
478 | ), |
479 | ); |
480 | validationResults.add( |
481 | _createValidationResult( |
482 | 'App Icon and Launch Image Assets Validation', |
483 | await _validateIconAssetsAfterArchive() + await _validateLaunchImageAssetsAfterArchive(), |
484 | ), |
485 | ); |
486 | |
487 | for (final ValidationResult result in validationResults.whereType<ValidationResult>()) { |
488 | globals.printStatus('\n${result.coloredLeadingBox} ${result.statusInfo} '); |
489 | for (final ValidationMessage message in result.messages) { |
490 | globals.printStatus( |
491 | '${message.coloredIndicator} ${message.message} ', |
492 | indent: result.leadingBox.length + 1, |
493 | ); |
494 | } |
495 | } |
496 | globals.printStatus( |
497 | '\nTo update the settings, please refer to https://flutter.dev/to/ios-deploy\n', |
498 | ); |
499 | |
500 | // xcarchive failed or not at expected location. |
501 | if (xcarchiveResult.exitStatus != ExitStatus.success) { |
502 | globals.printStatus('Skipping IPA.'); |
503 | return xcarchiveResult; |
504 | } |
505 | |
506 | if (!shouldCodesign) { |
507 | globals.printStatus('Codesigning disabled with --no-codesign, skipping IPA.'); |
508 | return xcarchiveResult; |
509 | } |
510 | |
511 | // Build IPA from generated xcarchive. |
512 | final BuildableIOSApp app = await buildableIOSApp; |
513 | Status? status; |
514 | RunResult? result; |
515 | final String relativeOutputPath = app.ipaOutputPath; |
516 | final String absoluteOutputPath = globals.fs.path.absolute(relativeOutputPath); |
517 | final String absoluteArchivePath = globals.fs.path.absolute(app.archiveBundleOutputPath); |
518 | String? exportOptions = exportOptionsPlist; |
519 | String? exportMethod = |
520 | exportOptions != null |
521 | ? globals.plistParser.getValueFromFile<String?>(exportOptions,'method') |
522 | : null; |
523 | exportMethod ??= _getVersionAppropriateExportMethod(stringArg('export-method')!); |
524 | final bool isAppStoreUpload = |
525 | exportMethod =='app-store'|| exportMethod =='app-store-connect'; |
526 | File? generatedExportPlist; |
527 | try { |
528 | final String exportMethodDisplayName = isAppStoreUpload ?'App Store': exportMethod; |
529 | status = globals.logger.startProgress('Building$exportMethodDisplayNameIPA...'); |
530 | if (exportOptions == null) { |
531 | generatedExportPlist = _createExportPlist(exportMethod); |
532 | exportOptions = generatedExportPlist.path; |
533 | } |
534 | |
535 | result = await globals.processUtils.run(<String>[ |
536 | ...globals.xcode!.xcrunCommand(), |
537 | 'xcodebuild', |
538 | '-exportArchive', |
539 | if (shouldCodesign) ...<String>[ |
540 | '-allowProvisioningDeviceRegistration', |
541 | '-allowProvisioningUpdates', |
542 | ], |
543 | '-archivePath', |
544 | absoluteArchivePath, |
545 | '-exportPath', |
546 | absoluteOutputPath, |
547 | '-exportOptionsPlist', |
548 | globals.fs.path.absolute(exportOptions), |
549 | ]); |
550 | } finally { |
551 | if (generatedExportPlist != null) { |
552 | ErrorHandlingFileSystem.deleteIfExists(generatedExportPlist); |
553 | } |
554 | status?.stop(); |
555 | } |
556 | |
557 | if (result.exitCode != 0) { |
558 | final StringBuffer errorMessage = StringBuffer(); |
559 | |
560 | // "error:" prefixed lines are the nicely formatted error message, the |
561 | // rest is the same message but printed as a IDEFoundationErrorDomain. |
562 | // Example: |
563 | // error: exportArchive: exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd |
564 | // Error Domain=IDEFoundationErrorDomain Code=1 "exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd" ... |
565 | LineSplitter.split( |
566 | result.stderr, |
567 | ).where((String line) => line.contains('error: ')).forEach(errorMessage.writeln); |
568 | |
569 | globals.printError('Encountered error while creating the IPA:'); |
570 | globals.printError(errorMessage.toString()); |
571 | |
572 | final FileSystemEntityType type = globals.fs.typeSync(absoluteArchivePath); |
573 | globals.printError('Try distributing the app in Xcode:'); |
574 | if (type == FileSystemEntityType.notFound) { |
575 | globals.printError('open ios/Runner.xcworkspace', indent: 2); |
576 | } else { |
577 | globals.printError('open$absoluteArchivePath', indent: 2); |
578 | } |
579 | |
580 | // Even though the IPA step didn't succeed, the xcarchive did. |
581 | // Still count this as success since the user has been instructed about how to |
582 | // recover in Xcode. |
583 | return FlutterCommandResult.success(); |
584 | } |
585 | |
586 | final Directory outputDirectory = globals.fs.directory(absoluteOutputPath); |
587 | final int? directorySize = globals.os.getDirectorySize(outputDirectory); |
588 | final String appSize = |
589 | (buildInfo.mode == BuildMode.debug || directorySize == null) |
590 | ?''// Don't display the size when building a debug variant. |
591 | :' (${getSizeAsPlatformMB(directorySize)})'; |
592 | |
593 | globals.printStatus( |
594 | '${globals.terminal.successMark}' |
595 | 'Built IPA to${globals.fs.path.relative(outputDirectory.path)}$appSize', |
596 | color: TerminalColor.green, |
597 | ); |
598 | |
599 | if (isAppStoreUpload) { |
600 | globals.printStatus('To upload to the App Store either:'); |
601 | globals.printStatus( |
602 | '1. Drag and drop the "$relativeOutputPath/*.ipa" bundle into the Apple Transporter macOS app https://apps.apple.com/us/app/transporter/id1450874784', |
603 | indent: 4, |
604 | ); |
605 | globals.printStatus( |
606 | '2. Run "xcrun altool --upload-app --type ios -f $relativeOutputPath/*.ipa --apiKey your_api_key --apiIssuer your_issuer_id".', |
607 | indent: 4, |
608 | ); |
609 | globals.printStatus( |
610 | 'See "man altool" for details about how to authenticate with the App Store Connect API key.', |
611 | indent: 7, |
612 | ); |
613 | } |
614 | |
615 | return FlutterCommandResult.success(); |
616 | } |
617 | |
618 | File _createExportPlist(String exportMethod) { |
619 | // Create the plist to be passed into xcodebuild -exportOptionsPlist. |
620 | final StringBuffer plistContents = StringBuffer(''' |
621 | |
622 | //Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
623 | |
624 | |
625 |
|
626 |
|
627 |
|
628 | |
629 | |
630 | |
631 | '''); |
632 | |
633 | final File tempPlist = globals.fs.systemTempDirectory |
634 | .createTempSync('flutter_build_ios.') |
635 | .childFile('ExportOptions.plist'); |
636 | tempPlist.writeAsStringSync(plistContents.toString()); |
637 | |
638 | return tempPlist; |
639 | } |
640 | |
641 | // As of Xcode 15.4, the old export methods 'app-store', 'ad-hoc', and 'development' |
642 | // are now deprecated. The new equivalents are 'app-store-connect', 'release-testing', |
643 | // and 'debugging'. |
644 | String _getVersionAppropriateExportMethod(String method) { |
645 | final Version? currVersion = globals.xcode!.currentVersion; |
646 | if (currVersion != null) { |
647 | if (currVersion >= Version(15, 4, 0)) { |
648 | switch (method) { |
649 | case 'app-store': |
650 | return 'app-store-connect'; |
651 | case 'ad-hoc': |
652 | return 'release-testing'; |
653 | case 'development': |
654 | return 'debugging'; |
655 | } |
656 | } |
657 | return method; |
658 | } |
659 | throwToolExit('Xcode version could not be found.'); |
660 | } |
661 | } |
662 | |
663 | abstract class _BuildIOSSubCommand extends BuildSubCommand { |
664 | _BuildIOSSubCommand({required super.logger, required bool verboseHelp}) |
665 | : super(verboseHelp: verboseHelp) { |
666 | addTreeShakeIconsFlag(); |
667 | addSplitDebugInfoOption(); |
668 | addBuildModeFlags(verboseHelp: verboseHelp); |
669 | usesTargetOption(); |
670 | usesFlavorOption(); |
671 | usesPubOption(); |
672 | usesBuildNumberOption(); |
673 | usesBuildNameOption(); |
674 | addDartObfuscationOption(); |
675 | usesDartDefineOption(); |
676 | usesExtraDartFlagOptions(verboseHelp: verboseHelp); |
677 | addEnableExperimentation(hide: !verboseHelp); |
678 | addBuildPerformanceFile(hide: !verboseHelp); |
679 | usesAnalyzeSizeFlag(); |
680 | argParser.addFlag( |
681 | 'codesign', |
682 | defaultsTo: true, |
683 | help: 'Codesign the application bundle (only available on device builds).', |
684 | ); |
685 | } |
686 | |
687 | @override |
688 | Future |
689 | DevelopmentArtifact.iOS, |
690 | }; |
691 | |
692 | XcodeBuildAction get xcodeBuildAction; |
693 | |
694 | /// The result of the Xcode build command. Null until it finishes. |
695 | @protected |
696 | XcodeBuildResult? xcodeBuildResult; |
697 | |
698 | EnvironmentType get environmentType; |
699 | bool get configOnly; |
700 | |
701 | bool get shouldCodesign => boolArg('codesign'); |
702 | |
703 | late final Future |
704 | |
705 | late final Future |
706 | final BuildableIOSApp? app = |
707 | await applicationPackages?.getPackageForPlatform( |
708 | TargetPlatform.ios, |
709 | buildInfo: await cachedBuildInfo, |
710 | ) |
711 | as BuildableIOSApp?; |
712 | |
713 | if (app == null) { |
714 | throwToolExit('Application not configured for iOS'); |
715 | } |
716 | return app; |
717 | }(); |
718 | |
719 | Directory _outputAppDirectory(String xcodeResultOutput); |
720 | |
721 | @override |
722 | bool get supported => globals.platform.isMacOS; |
723 | |
724 | @override |
725 | Future |
726 | defaultBuildMode = |
727 | environmentType == EnvironmentType.simulator ? BuildMode.debug : BuildMode.release; |
728 | final BuildInfo buildInfo = await cachedBuildInfo; |
729 | |
730 | if (!supported) { |
731 | throwToolExit('Building for iOS is only supported on macOS.'); |
732 | } |
733 | if (environmentType == EnvironmentType.simulator && !buildInfo.supportsSimulator) { |
734 | throwToolExit( |
735 | '${sentenceCase(buildInfo.friendlyModeName)} mode is not supported for simulators.', |
736 | ); |
737 | } |
738 | if (configOnly && buildInfo.codeSizeDirectory != null) { |
739 | throwToolExit('Cannot analyze code size without performing a full build.'); |
740 | } |
741 | if (environmentType == EnvironmentType.physical && !shouldCodesign) { |
742 | globals.printStatus( |
743 | 'Warning: Building for device with codesigning disabled. You will ' |
744 | 'have to manually codesign before deploying to device.', |
745 | ); |
746 | } |
747 | |
748 | final BuildableIOSApp app = await buildableIOSApp; |
749 | |
750 | final String logTarget = environmentType == EnvironmentType.simulator ? 'simulator' : 'device'; |
751 | final String typeName = globals.artifacts!.getEngineType(TargetPlatform.ios, buildInfo.mode); |
752 | globals.printStatus(switch (xcodeBuildAction) { |
753 | XcodeBuildAction.build => 'Building $app for $logTarget ($typeName)...', |
754 | XcodeBuildAction.archive => 'Archiving $app...', |
755 | }); |
756 | final XcodeBuildResult result = await buildXcodeProject( |
757 | app: app, |
758 | buildInfo: buildInfo, |
759 | targetOverride: targetFile, |
760 | environmentType: environmentType, |
761 | codesign: shouldCodesign, |
762 | configOnly: configOnly, |
763 | buildAction: xcodeBuildAction, |
764 | deviceID: globals.deviceManager?.specifiedDeviceId, |
765 | disablePortPublication: |
766 | usingCISystem && |
767 | xcodeBuildAction == XcodeBuildAction.build && |
768 | await disablePortPublication, |
769 | ); |
770 | xcodeBuildResult = result; |
771 | |
772 | if (!result.success) { |
773 | await diagnoseXcodeBuildFailure( |
774 | result, |
775 | analytics: globals.analytics, |
776 | fileSystem: globals.fs, |
777 | logger: globals.logger, |
778 | platform: SupportedPlatform.ios, |
779 | project: app.project.parent, |
780 | ); |
781 | final String presentParticiple = |
782 | xcodeBuildAction == XcodeBuildAction.build ? 'building' : 'archiving'; |
783 | throwToolExit('Encountered error while $presentParticiple for $logTarget.'); |
784 | } |
785 | |
786 | if (buildInfo.codeSizeDirectory != null) { |
787 | final SizeAnalyzer sizeAnalyzer = SizeAnalyzer( |
788 | fileSystem: globals.fs, |
789 | logger: globals.logger, |
790 | analytics: analytics, |
791 | appFilenamePattern: 'App', |
792 | ); |
793 | // Only support 64bit iOS code size analysis. |
794 | final String arch = DarwinArch.arm64.name; |
795 | final File aotSnapshot = globals.fs |
796 | .directory(buildInfo.codeSizeDirectory) |
797 | .childFile('snapshot.$arch.json'); |
798 | final File precompilerTrace = globals.fs |
799 | .directory(buildInfo.codeSizeDirectory) |
800 | .childFile('trace.$arch.json'); |
801 | |
802 | final String? resultOutput = result.output; |
803 | if (resultOutput == null) { |
804 | throwToolExit('Could not find app to analyze code size'); |
805 | } |
806 | final Directory outputAppDirectoryCandidate = _outputAppDirectory(resultOutput); |
807 | |
808 | Directory? appDirectory; |
809 | if (outputAppDirectoryCandidate.existsSync()) { |
810 | appDirectory = |
811 | outputAppDirectoryCandidate.listSync().whereType |
812 | Directory directory, |
813 | ) { |
814 | return globals.fs.path.extension(directory.path) == '.app'; |
815 | }).first; |
816 | } |
817 | if (appDirectory == null) { |
818 | throwToolExit( |
819 | 'Could not find app to analyze code size in ${outputAppDirectoryCandidate.path}', |
820 | ); |
821 | } |
822 | final Map |
823 | aotSnapshot: aotSnapshot, |
824 | precompilerTrace: precompilerTrace, |
825 | outputDirectory: appDirectory, |
826 | type: 'ios', |
827 | ); |
828 | final File outputFile = globals.fsUtils.getUniqueFile( |
829 | globals.fs.directory(globals.fsUtils.homeDirPath).childDirectory('.flutter-devtools'), |
830 | 'ios-code-size-analysis', |
831 | 'json', |
832 | )..writeAsStringSync(jsonEncode(output)); |
833 | // This message is used as a sentinel in analyze_apk_size_test.dart |
834 | globals.printStatus( |
835 | 'A summary of your iOS bundle analysis can be found at: ${outputFile.path}', |
836 | ); |
837 | |
838 | globals.printStatus( |
839 | '\nTo analyze your app size in Dart DevTools, run the following command:\n' |
840 | 'dart devtools --appSizeBase=${outputFile.path}', |
841 | ); |
842 | } |
843 | |
844 | if (result.output != null) { |
845 | final Directory outputDirectory = globals.fs.directory(result.output); |
846 | final int? directorySize = globals.os.getDirectorySize(outputDirectory); |
847 | final String appSize = |
848 | (buildInfo.mode == BuildMode.debug || directorySize == null) |
849 | ? '' // Don't display the size when building a debug variant. |
850 | : ' (${getSizeAsPlatformMB(directorySize)})'; |
851 | |
852 | globals.printStatus( |
853 | '${globals.terminal.successMark} ' |
854 | 'Built ${globals.fs.path.relative(outputDirectory.path)}$appSize', |
855 | color: TerminalColor.green, |
856 | ); |
857 | |
858 | // When an app is successfully built, record to analytics whether Impeller |
859 | // is enabled or disabled. Note that we report the _lack_ of an explicit |
860 | // flag set as "enabled" because the default is to enable Impeller on iOS. |
861 | final BuildableIOSApp app = await buildableIOSApp; |
862 | final String plistPath = app.project.infoPlist.path; |
863 | final bool? impellerEnabled = globals.plistParser.getValueFromFile |
864 | plistPath, |
865 | PlistParser.kFLTEnableImpellerKey, |
866 | ); |
867 | |
868 | final String buildLabel = |
869 | impellerEnabled == false ? 'plist-impeller-disabled' : 'plist-impeller-enabled'; |
870 | globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'ios')); |
871 | |
872 | return FlutterCommandResult.success(); |
873 | } |
874 | |
875 | return FlutterCommandResult.fail(); |
876 | } |
877 | } |
878 |
Definitions
- BuildIOSCommand
- BuildIOSCommand
- environmentType
- configOnly
- _outputAppDirectory
- _ImageAssetFileKey
- _ImageAssetFileKey
- hashCode
- ==
- pixelSize
- BuildIOSArchiveCommand
- BuildIOSArchiveCommand
- exportOptionsPlist
- _outputAppDirectory
- validateCommand
- _parseImageAssetContentsJson
- _isAssetStillUsingTemplateFiles
- _imageFilesWithWrongSize
- _createValidationResult
- _createValidationMessage
- _validateIconAssetsAfterArchive
- _validateLaunchImageAssetsAfterArchive
- _validateXcodeBuildSettingsAfterArchive
Learn more about Flutter for embedded and desktop on industrialflutter.com