// 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 '../application_package.dart'; import '../base/file_system.dart'; import '../build_info.dart'; import '../cache.dart'; import '../globals.dart' as globals; import '../template.dart'; import '../xcode_project.dart'; import 'plist_parser.dart'; /// Tests whether a [Directory] is an iOS bundle directory. bool _isBundleDirectory(Directory dir) => dir.path.endsWith('.app'); abstract class IOSApp extends ApplicationPackage { IOSApp({required String projectBundleId}) : super(id: projectBundleId); /// Creates a new IOSApp from an existing app bundle or IPA. static IOSApp? fromPrebuiltApp(FileSystemEntity applicationBinary) { final FileSystemEntityType entityType = globals.fs.typeSync(applicationBinary.path); if (entityType == FileSystemEntityType.notFound) { globals.printError( 'File "${applicationBinary.path}" does not exist. Use an app bundle or an ipa.'); return null; } Directory uncompressedBundle; if (entityType == FileSystemEntityType.directory) { final Directory directory = globals.fs.directory(applicationBinary); if (!_isBundleDirectory(directory)) { globals.printError('Folder "${applicationBinary.path}" is not an app bundle.'); return null; } uncompressedBundle = globals.fs.directory(applicationBinary); } else { // Try to unpack as an ipa. final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_app.'); globals.os.unzip(globals.fs.file(applicationBinary), tempDir); final Directory payloadDir = globals.fs.directory( globals.fs.path.join(tempDir.path, 'Payload'), ); if (!payloadDir.existsSync()) { globals.printError( 'Invalid prebuilt iOS ipa. Does not contain a "Payload" directory.'); return null; } try { uncompressedBundle = payloadDir.listSync().whereType<Directory>().singleWhere(_isBundleDirectory); } on StateError { globals.printError( 'Invalid prebuilt iOS ipa. Does not contain a single app bundle.'); return null; } } final String plistPath = globals.fs.path.join(uncompressedBundle.path, 'Info.plist'); if (!globals.fs.file(plistPath).existsSync()) { globals.printError('Invalid prebuilt iOS app. Does not contain Info.plist.'); return null; } final String? id = globals.plistParser.getStringValueFromFile( plistPath, PlistParser.kCFBundleIdentifierKey, ); if (id == null) { globals.printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier'); return null; } return PrebuiltIOSApp( uncompressedBundle: uncompressedBundle, bundleName: globals.fs.path.basename(uncompressedBundle.path), projectBundleId: id, applicationPackage: applicationBinary, ); } static Future<IOSApp?> fromIosProject(IosProject project, BuildInfo? buildInfo) async { if (!globals.platform.isMacOS) { return null; } if (!project.exists) { // If the project doesn't exist at all the current hint to run flutter // create is accurate. return null; } if (!project.xcodeProject.existsSync()) { globals.printError('Expected ios/Runner.xcodeproj but this file is missing.'); return null; } if (!project.xcodeProjectInfoFile.existsSync()) { globals.printError('Expected ios/Runner.xcodeproj/project.pbxproj but this file is missing.'); return null; } return BuildableIOSApp.fromProject(project, buildInfo); } @override String get displayName => id; String get simulatorBundlePath; String get deviceBundlePath; /// Directory used by ios-deploy to store incremental installation metadata for /// faster second installs. Directory? get appDeltaDirectory; } class BuildableIOSApp extends IOSApp { BuildableIOSApp(this.project, String projectBundleId, String? hostAppBundleName) : _hostAppBundleName = hostAppBundleName, super(projectBundleId: projectBundleId); static Future<BuildableIOSApp?> fromProject(IosProject project, BuildInfo? buildInfo) async { final String? hostAppBundleName = await project.hostAppBundleName(buildInfo); final String? projectBundleId = await project.productBundleIdentifier(buildInfo); if (projectBundleId != null) { return BuildableIOSApp(project, projectBundleId, hostAppBundleName); } return null; } final IosProject project; final String? _hostAppBundleName; @override String? get name => _hostAppBundleName; @override String get simulatorBundlePath => _buildAppPath('iphonesimulator'); @override String get deviceBundlePath => _buildAppPath('iphoneos'); @override Directory get appDeltaDirectory => globals.fs.directory(globals.fs.path.join(getIosBuildDirectory(), 'app-delta')); // Xcode uses this path for the final archive bundle location, // not a top-level output directory. // Specifying `build/ios/archive/Runner` will result in `build/ios/archive/Runner.xcarchive`. String get archiveBundlePath => globals.fs.path.join(getIosBuildDirectory(), 'archive', _hostAppBundleName == null ? 'Runner' : globals.fs.path.withoutExtension(_hostAppBundleName!)); // The output xcarchive bundle path `build/ios/archive/Runner.xcarchive`. String get archiveBundleOutputPath => globals.fs.path.setExtension(archiveBundlePath, '.xcarchive'); String get builtInfoPlistPathAfterArchive => globals.fs.path.join(archiveBundleOutputPath, 'Products', 'Applications', _hostAppBundleName == null ? 'Runner.app' : _hostAppBundleName!, 'Info.plist'); String get projectAppIconDirName => _projectImageAssetDirName(_appIconAsset); String get projectLaunchImageDirName => _projectImageAssetDirName(_launchImageAsset); String get templateAppIconDirNameForContentsJson => _templateImageAssetDirNameForContentsJson(_appIconAsset); String get templateLaunchImageDirNameForContentsJson => _templateImageAssetDirNameForContentsJson(_launchImageAsset); Future<String> get templateAppIconDirNameForImages async => _templateImageAssetDirNameForImages(_appIconAsset); Future<String> get templateLaunchImageDirNameForImages async => _templateImageAssetDirNameForImages(_launchImageAsset); String get ipaOutputPath => globals.fs.path.join(getIosBuildDirectory(), 'ipa'); String _buildAppPath(String type) { return globals.fs.path.join(getIosBuildDirectory(), type, _hostAppBundleName); } String _projectImageAssetDirName(String asset) => globals.fs.path.join('ios', 'Runner', 'Assets.xcassets', asset); // Template asset's Contents.json file is in flutter_tools, but the actual String _templateImageAssetDirNameForContentsJson(String asset) => globals.fs.path.join( Cache.flutterRoot!, 'packages', 'flutter_tools', 'templates', _templateImageAssetDirNameSuffix(asset), ); // Template asset's images are in flutter_template_images package. Future<String> _templateImageAssetDirNameForImages(String asset) async { final Directory imageTemplate = await templateImageDirectory(null, globals.fs, globals.logger); return globals.fs.path.join(imageTemplate.path, _templateImageAssetDirNameSuffix(asset)); } String _templateImageAssetDirNameSuffix(String asset) => globals.fs.path.join( 'app_shared', 'ios.tmpl', 'Runner', 'Assets.xcassets', asset, ); String get _appIconAsset => 'AppIcon.appiconset'; String get _launchImageAsset => 'LaunchImage.imageset'; } class PrebuiltIOSApp extends IOSApp implements PrebuiltApplicationPackage { PrebuiltIOSApp({ required this.uncompressedBundle, this.bundleName, required super.projectBundleId, required this.applicationPackage, }); /// The uncompressed bundle of the application. /// /// [IOSApp.fromPrebuiltApp] will uncompress the application into a temporary /// directory even when an `.ipa` file was used to create the [IOSApp] instance. final Directory uncompressedBundle; final String? bundleName; @override final Directory? appDeltaDirectory = null; @override String? get name => bundleName; @override String get simulatorBundlePath => _bundlePath; @override String get deviceBundlePath => _bundlePath; String get _bundlePath => uncompressedBundle.path; /// A [File] or [Directory] pointing to the application bundle. /// /// This can be either an `.ipa` file or an uncompressed `.app` directory. @override final FileSystemEntity applicationPackage; }