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:typed_data';
6
7import 'package:crypto/crypto.dart';
8import 'package:meta/meta.dart';
9import 'package:unified_analytics/unified_analytics.dart';
10
11import '../base/analyze_size.dart';
12import '../base/common.dart';
13import '../base/error_handling_io.dart';
14import '../base/file_system.dart';
15import '../base/logger.dart';
16import '../base/process.dart';
17import '../base/terminal.dart';
18import '../base/utils.dart';
19import '../base/version.dart';
20import '../build_info.dart';
21import '../convert.dart';
22import '../doctor_validator.dart';
23import '../globals.dart' as globals;
24import '../ios/application_package.dart';
25import '../ios/mac.dart';
26import '../ios/plist_parser.dart';
27import '../project.dart';
28import '../runner/flutter_command.dart';
29import '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.
33class 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
78class _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.
109class 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 $exportMethodDisplayName IPA...');
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 method
626 $exportMethod
627 uploadBitcode
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
663abstract 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> get requiredArtifacts async => const {
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 cachedBuildInfo = getBuildInfo();
704
705 late final Future buildableIOSApp = () async {
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 runCommand() async {
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().where((
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 output = await sizeAnalyzer.analyzeAotSnapshot(
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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com