| // 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:typed_data'; |
| |
| import 'package:crypto/crypto.dart'; |
| import 'package:file/file.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import '../base/analyze_size.dart'; |
| import '../base/common.dart'; |
| import '../base/logger.dart'; |
| import '../base/process.dart'; |
| import '../base/utils.dart'; |
| import '../build_info.dart'; |
| import '../convert.dart'; |
| import '../doctor_validator.dart'; |
| import '../globals.dart' as globals; |
| import '../ios/application_package.dart'; |
| import '../ios/mac.dart'; |
| import '../ios/plist_parser.dart'; |
| import '../reporting/reporting.dart'; |
| import '../runner/flutter_command.dart'; |
| import 'build.dart'; |
| |
| /// Builds an .app for an iOS app to be used for local testing on an iOS device |
| /// or simulator. Can only be run on a macOS host. |
| class BuildIOSCommand extends _BuildIOSSubCommand { |
| BuildIOSCommand({ required super.logger, required super.verboseHelp }) { |
| argParser |
| ..addFlag('config-only', |
| help: 'Update the project configuration without performing a build. ' |
| 'This can be used in CI/CD process that create an archive to avoid ' |
| 'performing duplicate work.' |
| ) |
| ..addFlag('simulator', |
| help: 'Build for the iOS simulator instead of the device. This changes ' |
| 'the default build mode to debug if otherwise unspecified.', |
| ); |
| } |
| |
| @override |
| final String name = 'ios'; |
| |
| @override |
| final String description = 'Build an iOS application bundle (macOS host only).'; |
| |
| @override |
| final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.build; |
| |
| @override |
| EnvironmentType get environmentType => boolArg('simulator') ? EnvironmentType.simulator : EnvironmentType.physical; |
| |
| @override |
| bool get configOnly => boolArg('config-only'); |
| |
| @override |
| Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput).parent; |
| } |
| |
| /// The key that uniquely identifies an image file in an image asset. |
| /// It consists of (idiom, scale, size?), where size is present for app icon |
| /// asset, and null for launch image asset. |
| @immutable |
| class _ImageAssetFileKey { |
| const _ImageAssetFileKey(this.idiom, this.scale, this.size); |
| |
| /// The idiom (iphone or ipad). |
| final String idiom; |
| /// The scale factor (e.g. 2). |
| final int scale; |
| /// The logical size in point (e.g. 83.5). |
| /// Size is present for app icon, and null for launch image. |
| final double? size; |
| |
| @override |
| int get hashCode => Object.hash(idiom, scale, size); |
| |
| @override |
| bool operator ==(Object other) => other is _ImageAssetFileKey |
| && other.idiom == idiom |
| && other.scale == scale |
| && other.size == size; |
| |
| /// The pixel size based on logical size and scale. |
| int? get pixelSize => size == null ? null : (size! * scale).toInt(); // pixel size must be an int. |
| } |
| |
| /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for |
| /// App Store submission. |
| /// |
| /// Can only be run on a macOS host. |
| class BuildIOSArchiveCommand extends _BuildIOSSubCommand { |
| BuildIOSArchiveCommand({required super.logger, required super.verboseHelp}) { |
| argParser.addOption( |
| 'export-method', |
| defaultsTo: 'app-store', |
| allowed: <String>['app-store', 'ad-hoc', 'development', 'enterprise'], |
| help: 'Specify how the IPA will be distributed.', |
| allowedHelp: <String, String>{ |
| 'app-store': 'Upload to the App Store.', |
| 'ad-hoc': 'Test on designated devices that do not need to be registered with the Apple developer account. ' |
| 'Requires a distribution certificate.', |
| 'development': 'Test only on development devices registered with the Apple developer account.', |
| 'enterprise': 'Distribute an app registered with the Apple Developer Enterprise Program.', |
| }, |
| ); |
| argParser.addOption( |
| 'export-options-plist', |
| valueHelp: 'ExportOptions.plist', |
| help: |
| 'Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.', |
| ); |
| } |
| |
| @override |
| final String name = 'ipa'; |
| |
| @override |
| final List<String> aliases = <String>['xcarchive']; |
| |
| @override |
| final String description = 'Build an iOS archive bundle and IPA for distribution (macOS host only).'; |
| |
| @override |
| final XcodeBuildAction xcodeBuildAction = XcodeBuildAction.archive; |
| |
| @override |
| final EnvironmentType environmentType = EnvironmentType.physical; |
| |
| @override |
| final bool configOnly = false; |
| |
| String? get exportOptionsPlist => stringArg('export-options-plist'); |
| |
| @override |
| Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs |
| .directory(xcodeResultOutput) |
| .childDirectory('Products') |
| .childDirectory('Applications'); |
| |
| @override |
| Future<void> validateCommand() async { |
| final String? exportOptions = exportOptionsPlist; |
| if (exportOptions != null) { |
| if (argResults?.wasParsed('export-method') ?? false) { |
| throwToolExit( |
| '"--export-options-plist" is not compatible with "--export-method". Either use "--export-options-plist" and ' |
| 'a plist describing how the IPA should be exported by Xcode, or use "--export-method" to create a new plist.\n' |
| 'See "xcodebuild -h" for available exportOptionsPlist keys.' |
| ); |
| } |
| final FileSystemEntityType type = globals.fs.typeSync(exportOptions); |
| if (type == FileSystemEntityType.notFound) { |
| throwToolExit( |
| '"$exportOptions" property list does not exist.'); |
| } else if (type != FileSystemEntityType.file) { |
| throwToolExit( |
| '"$exportOptions" is not a file. See "xcodebuild -h" for available keys.'); |
| } |
| } |
| return super.validateCommand(); |
| } |
| |
| // A helper function to parse Contents.json of an image asset into a map, |
| // with the key to be _ImageAssetFileKey, and value to be the image file name. |
| // Some assets have size (e.g. app icon) and others do not (e.g. launch image). |
| Map<_ImageAssetFileKey, String> _parseImageAssetContentsJson( |
| String contentsJsonDirName, |
| { required bool requiresSize }) |
| { |
| final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); |
| if (!contentsJsonDirectory.existsSync()) { |
| return <_ImageAssetFileKey, String>{}; |
| } |
| final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json'); |
| final Map<String, dynamic> contents = json.decode(contentsJsonFile.readAsStringSync()) as Map<String, dynamic>? ?? <String, dynamic>{}; |
| final List<dynamic> images = contents['images'] as List<dynamic>? ?? <dynamic>[]; |
| final Map<String, dynamic> info = contents['info'] as Map<String, dynamic>? ?? <String, dynamic>{}; |
| if ((info['version'] as int?) != 1) { |
| // Skips validation for unknown format. |
| return <_ImageAssetFileKey, String>{}; |
| } |
| |
| final Map<_ImageAssetFileKey, String> iconInfo = <_ImageAssetFileKey, String>{}; |
| for (final dynamic image in images) { |
| final Map<String, dynamic> imageMap = image as Map<String, dynamic>; |
| final String? idiom = imageMap['idiom'] as String?; |
| final String? size = imageMap['size'] as String?; |
| final String? scale = imageMap['scale'] as String?; |
| final String? fileName = imageMap['filename'] as String?; |
| |
| // requiresSize must match the actual presence of size in json. |
| if (requiresSize != (size != null) |
| || idiom == null || scale == null || fileName == null) |
| { |
| continue; |
| } |
| |
| final double? parsedSize; |
| if (size != null) { |
| // for example, "64x64". Parse the width since it is a square. |
| final Iterable<double> parsedSizes = size.split('x') |
| .map((String element) => double.tryParse(element)) |
| .whereType<double>(); |
| if (parsedSizes.isEmpty) { |
| continue; |
| } |
| parsedSize = parsedSizes.first; |
| } else { |
| parsedSize = null; |
| } |
| |
| // for example, "3x". |
| final Iterable<int> parsedScales = scale.split('x') |
| .map((String element) => int.tryParse(element)) |
| .whereType<int>(); |
| if (parsedScales.isEmpty) { |
| continue; |
| } |
| final int parsedScale = parsedScales.first; |
| iconInfo[_ImageAssetFileKey(idiom, parsedScale, parsedSize)] = fileName; |
| } |
| return iconInfo; |
| } |
| |
| // A helper function to check if an image asset is still using template files. |
| bool _isAssetStillUsingTemplateFiles({ |
| required Map<_ImageAssetFileKey, String> templateImageInfoMap, |
| required Map<_ImageAssetFileKey, String> projectImageInfoMap, |
| required String templateImageDirName, |
| required String projectImageDirName, |
| }) { |
| return projectImageInfoMap.entries.any((MapEntry<_ImageAssetFileKey, String> entry) { |
| final String projectFileName = entry.value; |
| final String? templateFileName = templateImageInfoMap[entry.key]; |
| if (templateFileName == null) { |
| return false; |
| } |
| final File projectFile = globals.fs.file( |
| globals.fs.path.join(projectImageDirName, projectFileName)); |
| final File templateFile = globals.fs.file( |
| globals.fs.path.join(templateImageDirName, templateFileName)); |
| |
| return projectFile.existsSync() |
| && templateFile.existsSync() |
| && md5.convert(projectFile.readAsBytesSync()) == |
| md5.convert(templateFile.readAsBytesSync()); |
| }); |
| } |
| |
| // A helper function to return a list of image files in an image asset with |
| // wrong sizes (as specified in its Contents.json file). |
| List<String> _imageFilesWithWrongSize({ |
| required Map<_ImageAssetFileKey, String> imageInfoMap, |
| required String imageDirName, |
| }) { |
| return imageInfoMap.entries.where((MapEntry<_ImageAssetFileKey, String> entry) { |
| final String fileName = entry.value; |
| final File imageFile = globals.fs.file(globals.fs.path.join(imageDirName, fileName)); |
| if (!imageFile.existsSync()) { |
| return false; |
| } |
| // validate image size is correct. |
| // PNG file's width is at byte [16, 20), and height is at byte [20, 24), in big endian format. |
| // Based on https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_format |
| final ByteData imageData = imageFile.readAsBytesSync().buffer.asByteData(); |
| if (imageData.lengthInBytes < 24) { |
| return false; |
| } |
| final int width = imageData.getInt32(16); |
| final int height = imageData.getInt32(20); |
| // The size must not be null. |
| final int expectedSize = entry.key.pixelSize!; |
| return width != expectedSize || height != expectedSize; |
| }) |
| .map((MapEntry<_ImageAssetFileKey, String> entry) => entry.value) |
| .toList(); |
| } |
| |
| ValidationResult? _createValidationResult(String title, List<ValidationMessage> messages) { |
| if (messages.isEmpty) { |
| return null; |
| } |
| final bool anyInvalid = messages.any((ValidationMessage message) => message.type != ValidationMessageType.information); |
| return ValidationResult( |
| anyInvalid ? ValidationType.partial : ValidationType.success, |
| messages, |
| statusInfo: title, |
| ); |
| } |
| |
| ValidationMessage _createValidationMessage({ |
| required bool isValid, |
| required String message, |
| }) { |
| // Use "information" type for valid message, and "hint" type for invalid message. |
| return isValid ? ValidationMessage(message) : ValidationMessage.hint(message); |
| } |
| |
| Future<List<ValidationMessage>> _validateIconAssetsAfterArchive() async { |
| final BuildableIOSApp app = await buildableIOSApp; |
| |
| final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( |
| app.templateAppIconDirNameForContentsJson, |
| requiresSize: true); |
| final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( |
| app.projectAppIconDirName, |
| requiresSize: true); |
| |
| final List<ValidationMessage> validationMessages = <ValidationMessage>[]; |
| |
| final bool usesTemplate = _isAssetStillUsingTemplateFiles( |
| templateImageInfoMap: templateInfoMap, |
| projectImageInfoMap: projectInfoMap, |
| templateImageDirName: await app.templateAppIconDirNameForImages, |
| projectImageDirName: app.projectAppIconDirName); |
| |
| if (usesTemplate) { |
| validationMessages.add(_createValidationMessage( |
| isValid: false, |
| message: 'App icon is set to the default placeholder icon. Replace with unique icons.', |
| )); |
| } |
| |
| final List<String> filesWithWrongSize = _imageFilesWithWrongSize( |
| imageInfoMap: projectInfoMap, |
| imageDirName: app.projectAppIconDirName); |
| |
| if (filesWithWrongSize.isNotEmpty) { |
| validationMessages.add(_createValidationMessage( |
| isValid: false, |
| message: 'App icon is using the incorrect size (e.g. ${filesWithWrongSize.first}).', |
| )); |
| } |
| return validationMessages; |
| } |
| |
| Future<List<ValidationMessage>> _validateLaunchImageAssetsAfterArchive() async { |
| final BuildableIOSApp app = await buildableIOSApp; |
| |
| final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( |
| app.templateLaunchImageDirNameForContentsJson, |
| requiresSize: false); |
| final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( |
| app.projectLaunchImageDirName, |
| requiresSize: false); |
| |
| final List<ValidationMessage> validationMessages = <ValidationMessage>[]; |
| |
| final bool usesTemplate = _isAssetStillUsingTemplateFiles( |
| templateImageInfoMap: templateInfoMap, |
| projectImageInfoMap: projectInfoMap, |
| templateImageDirName: await app.templateLaunchImageDirNameForImages, |
| projectImageDirName: app.projectLaunchImageDirName); |
| |
| if (usesTemplate) { |
| validationMessages.add(_createValidationMessage( |
| isValid: false, |
| message: 'Launch image is set to the default placeholder icon. Replace with unique launch image.', |
| )); |
| } |
| |
| return validationMessages; |
| } |
| |
| Future<List<ValidationMessage>> _validateXcodeBuildSettingsAfterArchive() async { |
| final BuildableIOSApp app = await buildableIOSApp; |
| |
| final String plistPath = app.builtInfoPlistPathAfterArchive; |
| |
| if (!globals.fs.file(plistPath).existsSync()) { |
| globals.printError('Invalid iOS archive. Does not contain Info.plist.'); |
| return <ValidationMessage>[]; |
| } |
| |
| final Map<String, String?> xcodeProjectSettingsMap = <String, String?>{}; |
| |
| xcodeProjectSettingsMap['Version Number'] = globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleShortVersionStringKey); |
| xcodeProjectSettingsMap['Build Number'] = globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleVersionKey); |
| xcodeProjectSettingsMap['Display Name'] = globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleDisplayNameKey) |
| ?? globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleNameKey); |
| xcodeProjectSettingsMap['Deployment Target'] = globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kMinimumOSVersionKey); |
| xcodeProjectSettingsMap['Bundle Identifier'] = globals.plistParser.getValueFromFile<String>(plistPath, PlistParser.kCFBundleIdentifierKey); |
| |
| final List<ValidationMessage> validationMessages = xcodeProjectSettingsMap.entries.map((MapEntry<String, String?> entry) { |
| final String title = entry.key; |
| final String? info = entry.value; |
| return _createValidationMessage( |
| isValid: info != null, |
| message: '$title: ${info ?? "Missing"}', |
| ); |
| }).toList(); |
| |
| final bool hasMissingSettings = xcodeProjectSettingsMap.values.any((String? element) => element == null); |
| if (hasMissingSettings) { |
| validationMessages.add(_createValidationMessage( |
| isValid: false, |
| message: 'You must set up the missing app settings.'), |
| ); |
| } |
| |
| final bool usesDefaultBundleIdentifier = xcodeProjectSettingsMap['Bundle Identifier']?.startsWith('com.example') ?? false; |
| if (usesDefaultBundleIdentifier) { |
| validationMessages.add(_createValidationMessage( |
| isValid: false, |
| message: 'Your application still contains the default "com.example" bundle identifier.'), |
| ); |
| } |
| |
| return validationMessages; |
| } |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| final BuildInfo buildInfo = await cachedBuildInfo; |
| displayNullSafetyMode(buildInfo); |
| final FlutterCommandResult xcarchiveResult = await super.runCommand(); |
| |
| final List<ValidationResult?> validationResults = <ValidationResult?>[]; |
| validationResults.add(_createValidationResult( |
| 'App Settings Validation', |
| await _validateXcodeBuildSettingsAfterArchive(), |
| )); |
| validationResults.add(_createValidationResult( |
| 'App Icon and Launch Image Assets Validation', |
| await _validateIconAssetsAfterArchive() + await _validateLaunchImageAssetsAfterArchive(), |
| )); |
| |
| for (final ValidationResult result in validationResults.whereType<ValidationResult>()) { |
| globals.printStatus('\n${result.coloredLeadingBox} ${result.statusInfo}'); |
| for (final ValidationMessage message in result.messages) { |
| globals.printStatus( |
| '${message.coloredIndicator} ${message.message}', |
| indent: result.leadingBox.length + 1, |
| ); |
| } |
| } |
| globals.printStatus('\nTo update the settings, please refer to https://docs.flutter.dev/deployment/ios\n'); |
| |
| // xcarchive failed or not at expected location. |
| if (xcarchiveResult.exitStatus != ExitStatus.success) { |
| globals.printStatus('Skipping IPA.'); |
| return xcarchiveResult; |
| } |
| |
| if (!shouldCodesign) { |
| globals.printStatus('Codesigning disabled with --no-codesign, skipping IPA.'); |
| return xcarchiveResult; |
| } |
| |
| // Build IPA from generated xcarchive. |
| final BuildableIOSApp app = await buildableIOSApp; |
| Status? status; |
| RunResult? result; |
| final String relativeOutputPath = app.ipaOutputPath; |
| final String absoluteOutputPath = globals.fs.path.absolute(relativeOutputPath); |
| final String absoluteArchivePath = globals.fs.path.absolute(app.archiveBundleOutputPath); |
| final String exportMethod = stringArg('export-method')!; |
| final bool isAppStoreUpload = exportMethod == 'app-store'; |
| File? generatedExportPlist; |
| try { |
| final String exportMethodDisplayName = isAppStoreUpload ? 'App Store' : exportMethod; |
| status = globals.logger.startProgress('Building $exportMethodDisplayName IPA...'); |
| String? exportOptions = exportOptionsPlist; |
| if (exportOptions == null) { |
| generatedExportPlist = _createExportPlist(); |
| exportOptions = generatedExportPlist.path; |
| } |
| |
| result = await globals.processUtils.run( |
| <String>[ |
| ...globals.xcode!.xcrunCommand(), |
| 'xcodebuild', |
| '-exportArchive', |
| if (shouldCodesign) ...<String>[ |
| '-allowProvisioningDeviceRegistration', |
| '-allowProvisioningUpdates', |
| ], |
| '-archivePath', |
| absoluteArchivePath, |
| '-exportPath', |
| absoluteOutputPath, |
| '-exportOptionsPlist', |
| globals.fs.path.absolute(exportOptions), |
| ], |
| ); |
| } finally { |
| generatedExportPlist?.deleteSync(); |
| status?.stop(); |
| } |
| |
| if (result.exitCode != 0) { |
| final StringBuffer errorMessage = StringBuffer(); |
| |
| // "error:" prefixed lines are the nicely formatted error message, the |
| // rest is the same message but printed as a IDEFoundationErrorDomain. |
| // Example: |
| // error: exportArchive: exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd |
| // Error Domain=IDEFoundationErrorDomain Code=1 "exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd" ... |
| LineSplitter.split(result.stderr) |
| .where((String line) => line.contains('error: ')) |
| .forEach(errorMessage.writeln); |
| |
| globals.printError('Encountered error while creating the IPA:'); |
| globals.printError(errorMessage.toString()); |
| globals.printError('Try distributing the app in Xcode: "open $absoluteArchivePath"'); |
| |
| // Even though the IPA step didn't succeed, the xcarchive did. |
| // Still count this as success since the user has been instructed about how to |
| // recover in Xcode. |
| return FlutterCommandResult.success(); |
| } |
| |
| globals.printStatus('Built IPA to $absoluteOutputPath.'); |
| |
| if (isAppStoreUpload) { |
| globals.printStatus('To upload to the App Store either:'); |
| globals.printStatus( |
| '1. Drag and drop the "$relativeOutputPath/*.ipa" bundle into the Apple Transporter macOS app https://apps.apple.com/us/app/transporter/id1450874784', |
| indent: 4, |
| ); |
| globals.printStatus( |
| '2. Run "xcrun altool --upload-app --type ios -f $relativeOutputPath/*.ipa --apiKey your_api_key --apiIssuer your_issuer_id".', |
| indent: 4, |
| ); |
| globals.printStatus( |
| 'See "man altool" for details about how to authenticate with the App Store Connect API key.', |
| indent: 7, |
| ); |
| } |
| |
| return FlutterCommandResult.success(); |
| } |
| |
| File _createExportPlist() { |
| // Create the plist to be passed into xcodebuild -exportOptionsPlist. |
| final StringBuffer plistContents = StringBuffer(''' |
| <?xml version="1.0" encoding="UTF-8"?> |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| <plist version="1.0"> |
| <dict> |
| <key>method</key> |
| <string>${stringArg('export-method')}</string> |
| <key>uploadBitcode</key> |
| <false/> |
| </dict> |
| </plist> |
| '''); |
| |
| final File tempPlist = globals.fs.systemTempDirectory |
| .createTempSync('flutter_build_ios.').childFile('ExportOptions.plist'); |
| tempPlist.writeAsStringSync(plistContents.toString()); |
| |
| return tempPlist; |
| } |
| } |
| |
| abstract class _BuildIOSSubCommand extends BuildSubCommand { |
| _BuildIOSSubCommand({ |
| required super.logger, |
| required bool verboseHelp |
| }) : super(verboseHelp: verboseHelp) { |
| addTreeShakeIconsFlag(); |
| addSplitDebugInfoOption(); |
| addBuildModeFlags(verboseHelp: verboseHelp); |
| usesTargetOption(); |
| usesFlavorOption(); |
| usesPubOption(); |
| usesBuildNumberOption(); |
| usesBuildNameOption(); |
| addDartObfuscationOption(); |
| usesDartDefineOption(); |
| usesExtraDartFlagOptions(verboseHelp: verboseHelp); |
| addEnableExperimentation(hide: !verboseHelp); |
| addBuildPerformanceFile(hide: !verboseHelp); |
| addBundleSkSLPathOption(hide: !verboseHelp); |
| addNullSafetyModeOptions(hide: !verboseHelp); |
| usesAnalyzeSizeFlag(); |
| argParser.addFlag('codesign', |
| defaultsTo: true, |
| help: 'Codesign the application bundle (only available on device builds).', |
| ); |
| } |
| |
| @override |
| Future<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{ |
| DevelopmentArtifact.iOS, |
| }; |
| |
| XcodeBuildAction get xcodeBuildAction; |
| |
| /// The result of the Xcode build command. Null until it finishes. |
| @protected |
| XcodeBuildResult? xcodeBuildResult; |
| |
| EnvironmentType get environmentType; |
| bool get configOnly; |
| |
| bool get shouldCodesign => boolArg('codesign'); |
| |
| late final Future<BuildInfo> cachedBuildInfo = getBuildInfo(); |
| |
| late final Future<BuildableIOSApp> buildableIOSApp = () async { |
| final BuildableIOSApp? app = await applicationPackages?.getPackageForPlatform( |
| TargetPlatform.ios, |
| buildInfo: await cachedBuildInfo, |
| ) as BuildableIOSApp?; |
| |
| if (app == null) { |
| throwToolExit('Application not configured for iOS'); |
| } |
| return app; |
| }(); |
| |
| Directory _outputAppDirectory(String xcodeResultOutput); |
| |
| @override |
| bool get supported => globals.platform.isMacOS; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| defaultBuildMode = environmentType == EnvironmentType.simulator ? BuildMode.debug : BuildMode.release; |
| final BuildInfo buildInfo = await cachedBuildInfo; |
| |
| if (!supported) { |
| throwToolExit('Building for iOS is only supported on macOS.'); |
| } |
| if (environmentType == EnvironmentType.simulator && !buildInfo.supportsSimulator) { |
| throwToolExit('${sentenceCase(buildInfo.friendlyModeName)} mode is not supported for simulators.'); |
| } |
| if (configOnly && buildInfo.codeSizeDirectory != null) { |
| throwToolExit('Cannot analyze code size without performing a full build.'); |
| } |
| if (environmentType == EnvironmentType.physical && !shouldCodesign) { |
| globals.printStatus( |
| 'Warning: Building for device with codesigning disabled. You will ' |
| 'have to manually codesign before deploying to device.', |
| ); |
| } |
| |
| final BuildableIOSApp app = await buildableIOSApp; |
| |
| final String logTarget = environmentType == EnvironmentType.simulator ? 'simulator' : 'device'; |
| final String typeName = globals.artifacts!.getEngineType(TargetPlatform.ios, buildInfo.mode); |
| if (xcodeBuildAction == XcodeBuildAction.build) { |
| globals.printStatus('Building $app for $logTarget ($typeName)...'); |
| } else { |
| globals.printStatus('Archiving $app...'); |
| } |
| final XcodeBuildResult result = await buildXcodeProject( |
| app: app, |
| buildInfo: buildInfo, |
| targetOverride: targetFile, |
| environmentType: environmentType, |
| codesign: shouldCodesign, |
| configOnly: configOnly, |
| buildAction: xcodeBuildAction, |
| deviceID: globals.deviceManager?.specifiedDeviceId, |
| ); |
| xcodeBuildResult = result; |
| |
| if (!result.success) { |
| await diagnoseXcodeBuildFailure(result, globals.flutterUsage, globals.logger); |
| final String presentParticiple = xcodeBuildAction == XcodeBuildAction.build ? 'building' : 'archiving'; |
| throwToolExit('Encountered error while $presentParticiple for $logTarget.'); |
| } |
| |
| if (buildInfo.codeSizeDirectory != null) { |
| final SizeAnalyzer sizeAnalyzer = SizeAnalyzer( |
| fileSystem: globals.fs, |
| logger: globals.logger, |
| flutterUsage: globals.flutterUsage, |
| appFilenamePattern: 'App' |
| ); |
| // Only support 64bit iOS code size analysis. |
| final String arch = DarwinArch.arm64.name; |
| final File aotSnapshot = globals.fs.directory(buildInfo.codeSizeDirectory) |
| .childFile('snapshot.$arch.json'); |
| final File precompilerTrace = globals.fs.directory(buildInfo.codeSizeDirectory) |
| .childFile('trace.$arch.json'); |
| |
| final String? resultOutput = result.output; |
| if (resultOutput == null) { |
| throwToolExit('Could not find app to analyze code size'); |
| } |
| final Directory outputAppDirectoryCandidate = _outputAppDirectory(resultOutput); |
| |
| Directory? appDirectory; |
| if (outputAppDirectoryCandidate.existsSync()) { |
| appDirectory = outputAppDirectoryCandidate.listSync() |
| .whereType<Directory>() |
| .where((Directory directory) { |
| return globals.fs.path.extension(directory.path) == '.app'; |
| }).first; |
| } |
| if (appDirectory == null) { |
| throwToolExit('Could not find app to analyze code size in ${outputAppDirectoryCandidate.path}'); |
| } |
| final Map<String, Object?> output = await sizeAnalyzer.analyzeAotSnapshot( |
| aotSnapshot: aotSnapshot, |
| precompilerTrace: precompilerTrace, |
| outputDirectory: appDirectory, |
| type: 'ios', |
| ); |
| final File outputFile = globals.fsUtils.getUniqueFile( |
| globals.fs |
| .directory(globals.fsUtils.homeDirPath) |
| .childDirectory('.flutter-devtools'), 'ios-code-size-analysis', 'json', |
| )..writeAsStringSync(jsonEncode(output)); |
| // This message is used as a sentinel in analyze_apk_size_test.dart |
| globals.printStatus( |
| 'A summary of your iOS bundle analysis can be found at: ${outputFile.path}', |
| ); |
| |
| // DevTools expects a file path relative to the .flutter-devtools/ dir. |
| final String relativeAppSizePath = outputFile.path.split('.flutter-devtools/').last.trim(); |
| globals.printStatus( |
| '\nTo analyze your app size in Dart DevTools, run the following command:\n' |
| 'dart devtools --appSizeBase=$relativeAppSizePath' |
| ); |
| } |
| |
| if (result.output != null) { |
| globals.printStatus('Built ${result.output}.'); |
| |
| // When an app is successfully built, record to analytics whether Impeller |
| // is enabled or disabled. |
| final BuildableIOSApp app = await buildableIOSApp; |
| final String plistPath = app.project.infoPlist.path; |
| final bool? impellerEnabled = globals.plistParser.getValueFromFile<bool>( |
| plistPath, PlistParser.kFLTEnableImpellerKey, |
| ); |
| BuildEvent( |
| impellerEnabled == false |
| ? 'plist-impeller-disabled' |
| : 'plist-impeller-enabled', |
| type: 'ios', |
| flutterUsage: globals.flutterUsage, |
| ).send(); |
| |
| return FlutterCommandResult.success(); |
| } |
| |
| return FlutterCommandResult.fail(); |
| } |
| } |