blob: 0f164684719834e497be0412559821ed1cd9d021 [file] [log] [blame]
// 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.
/// @docImport 'application_package.dart';
library;
import 'dart:async';
import 'package:process/process.dart';
import '../base/common.dart';
import '../base/config.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/terminal.dart';
import '../convert.dart' show utf8;
import 'plist_parser.dart';
const _developmentTeamBuildSettingName = 'DEVELOPMENT_TEAM';
const _codeSignStyleBuildSettingName = 'CODE_SIGN_STYLE';
const _provisioningProfileSpecifierBuildSettingName = 'PROVISIONING_PROFILE_SPECIFIER';
const _provisioningProfileBuildSettingName = 'PROVISIONING_PROFILE';
const _codeSignSelectionCanceled = 'Code-signing setup canceled. Your changes have not been saved.';
/// User message when no development certificates are found in the keychain.
///
/// The user likely never did any iOS development.
const noCertificatesInstruction =
'''
════════════════════════════════════════════════════════════════════════════════
No valid code signing certificates were found
You can connect to your Apple Developer account by signing in with your Apple ID
in Xcode and create an iOS Development Certificate as well as a Provisioning\u0020
Profile for your project by:
$fixWithDevelopmentTeamInstruction
5- Trust your newly created Development Certificate on your iOS device
via Settings > General > Device Management > [your new certificate] > Trust
For more information, please visit:
https://developer.apple.com/library/content/documentation/IDEs/Conceptual/
AppDistributionGuide/MaintainingCertificates/MaintainingCertificates.html
Or run on an iOS simulator without code signing
════════════════════════════════════════════════════════════════════════════════''';
/// User message when there are no provisioning profile for the current app bundle identifier.
///
/// The user did iOS development but never on this project and/or device.
const noProvisioningProfileInstruction =
'''
════════════════════════════════════════════════════════════════════════════════
No Provisioning Profile was found for your project's Bundle Identifier or your\u0020
device. You can create a new Provisioning Profile for your project in Xcode for\u0020
your team by:
$fixWithDevelopmentTeamInstruction
It's also possible that a previously installed app with the same Bundle\u0020
Identifier was signed with a different certificate.
For more information, please visit:
https://flutter.dev/to/ios-app-signing
Or run on an iOS simulator without code signing
════════════════════════════════════════════════════════════════════════════════''';
/// Fallback error message for signing issues.
///
/// Couldn't auto sign the app but can likely solved by retracing the signing flow in Xcode.
const noDevelopmentTeamInstruction =
'''
════════════════════════════════════════════════════════════════════════════════
Building a deployable iOS app requires a selected Development Team with a\u0020
Provisioning Profile. Please ensure that a Development Team is selected by:
$fixWithDevelopmentTeamInstruction
For more information, please visit:
https://flutter.dev/to/ios-development-team
Or run on an iOS simulator without code signing
════════════════════════════════════════════════════════════════════════════════''';
const fixWithDevelopmentTeamInstruction = '''
1- Open the Flutter project's Xcode target with
open ios/Runner.xcworkspace
2- Select the 'Runner' project in the navigator then the 'Runner' target
in the project settings
3- Make sure a 'Development Team' is selected under Signing & Capabilities > Team.\u0020
You may need to:
- Log in with your Apple ID in Xcode first
- Ensure you have a valid unique Bundle ID
- Register your device with your Apple Developer Account
- Let Xcode automatically provision a profile for your app
4- Build or run your project again''';
/// Pattern to extract identity from list of identities.
///
/// Example:
///
/// ` 1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)"`
/// extracts `iPhone Developer: Profile 1 (1111AAAA11)`
final _securityFindIdentityDeveloperIdentityExtractionPattern = RegExp(
r'^\s*\d+\).+"(.+Develop(ment|er).+)"$',
);
/// Pattern to extract unique identifier from certificate Common Name.
///
/// Example:
///
/// `iPhone Developer: Profile 1 (1111AAAA11)`
/// extracts `1111AAAA11`
final _securityFindIdentityCertificateCnExtractionPattern = RegExp(r'.*\(([a-zA-Z0-9]+)\)');
/// Pattern to extract OU (Organizational Unit) from certificate subject.
///
/// Example:
///
/// `subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O/O=Company LLC/C=US`
/// extracts `ABCDE1F2DH`
final _certificateOrganizationalUnitExtractionPattern = RegExp(r'OU=([a-zA-Z0-9]+)');
/// Pattern to extract CN (Common Name) from certificate subject.
///
/// Example:
///
/// `subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O/O=Company LLC/C=US`
/// extracts `Apple Development: Company Development (12ABCD234E)`
final _certificateCommonNameExtractionPattern = RegExp(r'CN=([a-zA-Z0-9\s:\(\)]+)');
/// Given a [BuildableIOSApp], find build settings for either automatic (identity)
/// or manual (provisioning profile) code-signing.
///
/// If a valid provisioning profile or code-signing identity is saved in the
/// config, it will use that. Otherwise, it will try to find valid development
/// code-signing identities in the user's keychain, prompting a choice if multiple
/// are found.
///
/// Throws an error if the user cancels identity selection or if no identities
/// are found.
///
/// Will return `null` if the `DEVELOPMENT_TEAM` or `PROVISIONING_PROFILE` are
/// already set in the Xcode project's build settings.
Future<Map<String, String>?> getCodeSigningIdentityDevelopmentTeamBuildSetting({
required Map<String, String> buildSettings,
required ProcessManager processManager,
required Platform platform,
required Logger logger,
required Config config,
required Terminal terminal,
required FileSystem fileSystem,
required FileSystemUtils fileSystemUtils,
required PlistParser plistParser,
}) async {
// If the user already has it set in the project build settings itself,
// continue with that.
if (_isNotEmpty(buildSettings[_developmentTeamBuildSettingName])) {
logger.printStatus(
'Automatically signing iOS for device deployment using specified development '
'team in Xcode project: ${buildSettings[_developmentTeamBuildSettingName]}',
);
return null;
}
if (_isNotEmpty(buildSettings[_provisioningProfileBuildSettingName])) {
return null;
}
final settings = XcodeCodeSigningSettings(
config: config,
logger: logger,
platform: platform,
processUtils: ProcessUtils(processManager: processManager, logger: logger),
fileSystem: fileSystem,
fileSystemUtils: fileSystemUtils,
terminal: terminal,
plistParser: plistParser,
);
return settings._getCodeSigningBuildSettings();
}
/// Returns the `DEVELOPMENT_TEAM` for automatic code-signing.
/// This function should not be used for manual code-signing.
///
/// This finds the `DEVELOPMENT_TEAM` from the saved `ios-signing-cert` or prompt the
/// user to select a code-signing identity for automatic code-signing if
/// `ios-signing-cert` is not saved or invalid.
///
/// If `ios-signing-profile` (manual code-signing with a provisioning profile)
/// is saved instead, returns `null`.
Future<String?> getCodeSigningIdentityDevelopmentTeam({
required ProcessManager processManager,
required Platform platform,
required Logger logger,
required Config config,
required Terminal terminal,
required FileSystem fileSystem,
required FileSystemUtils fileSystemUtils,
required PlistParser plistParser,
}) async {
final settings = XcodeCodeSigningSettings(
config: config,
logger: logger,
platform: platform,
processUtils: ProcessUtils(processManager: processManager, logger: logger),
fileSystem: fileSystem,
fileSystemUtils: fileSystemUtils,
terminal: terminal,
plistParser: plistParser,
);
final Map<String, String>? buildSettings = await settings._getCodeSigningBuildSettings(
shouldExitOnNoCerts: false,
automaticCodeSignStyleOnly: true,
);
return buildSettings?[_developmentTeamBuildSettingName];
}
class XcodeCodeSigningSettings {
XcodeCodeSigningSettings({
required Config config,
required Logger logger,
required Platform platform,
required ProcessUtils processUtils,
required FileSystem fileSystem,
required FileSystemUtils fileSystemUtils,
required Terminal terminal,
required PlistParser plistParser,
}) : _config = config,
_logger = logger,
_platform = platform,
_processUtils = processUtils,
_fileSystem = fileSystem,
_fileSystemUtils = fileSystemUtils,
_plistParser = plistParser,
_terminal = terminal;
final Config _config;
final Logger _logger;
final Platform _platform;
final ProcessUtils _processUtils;
final FileSystem _fileSystem;
final FileSystemUtils _fileSystemUtils;
final Terminal _terminal;
final PlistParser _plistParser;
/// Config key for saved code-signing identity. A code-signing identity is a
/// combination of a certificate and the private key that matches the public
/// key in that certificate.
///
/// Example: Apple Development: My Name (ABC1234EFG)
static const kConfigCodeSignCertificate = 'ios-signing-cert';
/// Config key for saved provisioning profile file path. A provisioning profile
/// sets criteria for who is allowed to sign code, what apps are allowed to be
/// signed, where and when those apps can be run and how those apps are entitled.
///
/// Example: ~/Library/Developer/Xcode/UserData/Provisioning Profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision
static const kConfigCodeSignProvisioningProfile = 'ios-signing-profile';
/// Reset both [kConfigCodeSignCertificate] and [kConfigCodeSignProvisioningProfile]
/// config settings.
static void resetSettings(Config config, Logger logger) {
config.removeValue(kConfigCodeSignCertificate);
logger.printStatus('Removing "$kConfigCodeSignCertificate" value.');
config.removeValue(kConfigCodeSignProvisioningProfile);
logger.printStatus('Removing "$kConfigCodeSignProvisioningProfile" value.');
}
/// Get and validate code-sign settings from the config (provisioning profile
/// or identity) or prompt the user to select a code-signing identity for
/// automatic code-signing.
///
/// If a provisioning profile is saved, return settings with `CODE_SIGN_STYLE=Manual`,
/// `DEVELOPMENT_TEAM`, and `PROVISIONING_PROFILE_SPECIFIER`. If the profile
/// is no longer valid, return null.
///
/// If a identity is saved, return settings with `DEVELOPMENT_TEAM`. If no
/// settings are found or if the saved identity is no longer valid, prompt
/// the user to select a code-signing identity for automatic code-signing.
///
/// If [shouldExitOnNoCerts] is true, throw a toolExit if no code-signing
/// identities are found.
///
/// If [automaticCodeSignStyleOnly] is true, return null if a provisioning
/// profile is saved in the config.
Future<Map<String, String>?> _getCodeSigningBuildSettings({
bool shouldExitOnNoCerts = true,
bool automaticCodeSignStyleOnly = false,
}) async {
if (!_platform.isMacOS) {
_logger.printTrace('Unable to get code-sign settings on non-Mac platform.');
return null;
}
final bool toolsValidated = await _validateCodeSignSearchTools();
if (!toolsValidated) {
return null;
}
final List<String> validCodeSigningIdentities = await _getSigningIdentities();
if (validCodeSigningIdentities.isEmpty) {
if (shouldExitOnNoCerts) {
_logger.printError(noCertificatesInstruction, emphasis: true);
throwToolExit(
'No development certificates available to code sign app for device deployment',
);
} else {
_logger.printTrace(
'No development certificates available to code sign app for device deployment',
);
return null;
}
}
final savedProfile =
_config.getValue(XcodeCodeSigningSettings.kConfigCodeSignProvisioningProfile) as String?;
if (savedProfile != null) {
// Provisioning profile should be used for manual signing.
if (automaticCodeSignStyleOnly) {
return null;
}
final _ProvisioningProfile? validatedProfile = await _validateSavedProfile(
savedProfile,
validCodeSigningIdentities,
);
if (validatedProfile == null) {
return null;
}
_logger.printStatus(
'Provisioning profile "${validatedProfile.name}" selected for iOS code signing',
);
return <String, String>{
_codeSignStyleBuildSettingName: _CodeSigningStyle.manual.label,
_developmentTeamBuildSettingName: validatedProfile.teamIdentifier,
_provisioningProfileSpecifierBuildSettingName: validatedProfile.name,
};
}
final savedCertChoice =
_config.getValue(XcodeCodeSigningSettings.kConfigCodeSignCertificate) as String?;
String? identity;
if (savedCertChoice != null) {
identity = _validateSavedIdentity(savedCertChoice, validCodeSigningIdentities);
if (identity == null) {
_logger.printError(
'Saved signing certificate "$savedCertChoice" is not a valid development '
'certificate. To clear, use "flutter config --clear-ios-signing-settings"',
);
}
}
if (identity == null) {
identity = await _selectSigningIdentity(
validCodeSigningIdentities,
autoSelectSingle: true,
throwOnCancel: true,
);
if (identity == null) {
return null;
}
}
final String? developmentTeam = await _getDevelopmentTeamFromIdentity(identity);
if (developmentTeam == null) {
return null;
}
_logger.printStatus('Developer identity "$identity" selected for iOS code signing');
return <String, String>{_developmentTeamBuildSettingName: developmentTeam};
}
void _saveCodeSignIdentity(String identity) {
_logger.printStatus('Certificate choice "$identity" saved.');
_config.setValue(kConfigCodeSignCertificate, identity);
}
void _saveProvisioningProfile(_ProvisioningProfile profile) {
_logger.printStatus('Provisioning Profile "${profile.name}" saved.');
_config.setValue(kConfigCodeSignProvisioningProfile, profile.filePath);
}
/// Validates that command-line tools `security` and `openssl` are available.
Future<bool> _validateCodeSignSearchTools({bool printError = false}) async {
// If the user's environment is missing the tools needed to find and read
// certificates, abandon. Tools should be pre-equipped on macOS.
if (!await _processUtils.exitsHappy(const <String>['which', 'security']) ||
!await _processUtils.exitsHappy(const <String>['which', 'openssl'])) {
if (printError) {
_logger.printError('Unable to validate code-signing tools `security` and/or `openssl`.');
} else {
_logger.printTrace('Unable to validate code-signing tools `security` and/or `openssl`.');
}
return false;
}
return true;
}
/// Get list of code-signing identities.
Future<List<String>> _getSigningIdentities() async {
String findIdentityStdout;
try {
findIdentityStdout = (await _processUtils.run(<String>[
'security',
'find-identity',
'-p',
'codesigning',
'-v',
], throwOnError: true)).stdout.trim();
} on ProcessException catch (error) {
_logger.printError('Unexpected failure from find-identity: $error.');
return <String>[];
}
return findIdentityStdout
.split('\n')
.map<String?>((String outputLine) {
return _securityFindIdentityDeveloperIdentityExtractionPattern
.firstMatch(outputLine)
?.group(1);
})
.where(_isNotEmpty)
.whereType<String>()
.toSet() // Unique.
.toList();
}
/// Validates the saved provisioning profile still exists and that there is a
/// valid identity/certificate for the profile.
///
/// Returns null if profile cannot be found, parsed, or validated.
Future<_ProvisioningProfile?> _validateSavedProfile(
String savedProfilePath,
List<String> validCodeSigningIdentities,
) async {
final File savedProfile = _fileSystem.file(savedProfilePath);
if (!savedProfile.existsSync()) {
_logger.printError('Unable to find saved provisioning profile $savedProfilePath');
return null;
}
final _ProvisioningProfile? parsedProfile = await _parseProvisioningProfile(savedProfile);
if (parsedProfile == null) {
return null;
}
for (final File cert in parsedProfile.developerCertificates) {
final String? identity = await _validateIdentityFromCert(cert, validCodeSigningIdentities);
if (identity != null) {
return parsedProfile;
}
}
_logger.printError(
'Unable to find a valid certificate matching the provisioning profile $savedProfilePath',
);
return null;
}
/// Decode and convert a .mobileprovision file to a .plist file and then
/// parse the .plist into [_ProvisioningProfile].
Future<_ProvisioningProfile?> _parseProvisioningProfile(File provisioningProfileFile) async {
final Directory profilesDirectory = _fileSystem.systemTempDirectory.childDirectory(
'provisioning_profiles',
);
profilesDirectory.createSync(recursive: true);
final File decodedProfile = profilesDirectory.childFile(
'decoded_profile_${provisioningProfileFile.basename}.plist',
);
try {
await _processUtils.run(<String>[
'security',
'cms',
'-D',
'-i',
provisioningProfileFile.path,
'-o',
decodedProfile.path,
], throwOnError: true);
} on ProcessException catch (error) {
_logger.printError('Unexpected failure from security: $error.');
return null;
}
if (!decodedProfile.existsSync()) {
_logger.printError('Failed to decode ${provisioningProfileFile.basename}');
return null;
}
try {
final Map<String, Object> contents = _plistParser.parseFile(decodedProfile.path);
return _ProvisioningProfile.fromPlist(
provisioningProfileFile.path,
contents,
fileSystem: _fileSystem,
);
} on Exception catch (e) {
_logger.printError('Failed to parse provisioning profile: $e');
return null;
}
}
/// Extract the Common Name from the [certificate] and then search for
/// matching identities in [validCodeSigningIdentities]. Return the first
/// matching.
Future<String?> _validateIdentityFromCert(
File certificate,
List<String> validCodeSigningIdentities,
) async {
final String resultsStdOut;
try {
final RunResult results = await _processUtils.run(<String>[
'openssl',
'x509',
'-subject',
'-in',
certificate.path,
'-inform',
'DER',
], throwOnError: true);
resultsStdOut = results.stdout;
} on ProcessException catch (error) {
_logger.printError('Unexpected failure from openssl: $error.');
return null;
}
final String? commonName = _certificateCommonNameExtractionPattern
.firstMatch(resultsStdOut)
?.group(1);
if (commonName == null) {
_logger.printError('Unable to extract Common Name from certificate.');
return null;
}
return validCodeSigningIdentities.where((String id) => id.contains(commonName)).firstOrNull;
}
/// Returns [identity] if it is found within [validCodeSigningIdentities] and
/// prints a message that it was found.
String? _validateSavedIdentity(String identity, List<String> validCodeSigningIdentities) {
if (validCodeSigningIdentities.contains(identity)) {
_logger.printStatus(
'Found saved certificate choice "$identity". To clear, use "flutter config '
'--clear-ios-signing-settings".',
);
return identity;
}
return null;
}
/// Find the certificate for the [identity] and extract the development team /
/// organizational unit from the certificate.
Future<String?> _getDevelopmentTeamFromIdentity(String identity) async {
final String? signingCertificateId = _securityFindIdentityCertificateCnExtractionPattern
.firstMatch(identity)
?.group(1);
// If `security`'s output format changes, we'd have to update the above regex.
if (signingCertificateId == null) {
_logger.printError('Unable to parse common name from code-signing certificate $identity');
return null;
}
String signingCertificateStdout;
try {
signingCertificateStdout = (await _processUtils.run(<String>[
'security',
'find-certificate',
'-c',
signingCertificateId,
'-p',
], throwOnError: true)).stdout.trim();
} on ProcessException catch (error) {
_logger.printError('Unexpected error from security: $error');
return null;
}
final Process opensslProcess = await _processUtils.start(const <String>[
'openssl',
'x509',
'-subject',
]);
await ProcessUtils.writeToStdinGuarded(
stdin: opensslProcess.stdin,
content: signingCertificateStdout,
onError: (Object? error, _) {
throw Exception('Unexpected error when writing to openssl: $error');
},
);
await opensslProcess.stdin.close();
final String opensslOutput = await utf8.decodeStream(opensslProcess.stdout);
// Fire and forget discard of the stderr stream so we don't hold onto resources.
// Don't care about the result.
unawaited(opensslProcess.stderr.drain<String?>());
if (await opensslProcess.exitCode != 0) {
_logger.printError('Failed to get subject name for code-signing certificate $identity');
return null;
}
final String? developmentTeam = _certificateOrganizationalUnitExtractionPattern
.firstMatch(opensslOutput)
?.group(1);
if (developmentTeam == null) {
_logger.printError(
'Unable to parse development team from code-signing certificate $identity',
);
return null;
}
return developmentTeam;
}
/// Select code-signinging settings and save to config.
///
/// Available options include automatic signing with a code-signing identity
/// or manual code-signing with a provisioning profile.
Future<void> selectSettings() async {
// If terminal UI can't be used, just attempt with the first valid certificate
// since we can't ask the user.
if (!_terminal.stdinHasTerminal) {
_logger.printError(
'Unable to detect stdin for the terminal. Code-signing selection requires stdin.',
);
return;
}
_terminal.usesTerminalUi = true;
final bool toolsValidated = await _validateCodeSignSearchTools(printError: true);
if (!toolsValidated) {
return;
}
final savedCertChoice =
_config.getValue(XcodeCodeSigningSettings.kConfigCodeSignCertificate) as String?;
final savedProfile =
_config.getValue(XcodeCodeSigningSettings.kConfigCodeSignProvisioningProfile) as String?;
if (savedCertChoice != null || savedProfile != null) {
_logger.printError(
'Code-signing settings are already set. To reset them, use "flutter config '
'--clear-ios-signing-settings"',
);
return;
}
final _CodeSigningStyle? style = await _selectSigningStyle();
if (style == null) {
_logger.printWarning(_codeSignSelectionCanceled);
return;
}
if (style == _CodeSigningStyle.automatic) {
final List<String> validCodeSigningIdentities = await _getSigningIdentities();
if (validCodeSigningIdentities.isEmpty) {
_logger.printError(noCertificatesInstruction, emphasis: true);
_logger.printWarning(_codeSignSelectionCanceled);
return;
}
final String? identity = await _selectSigningIdentity(validCodeSigningIdentities);
if (identity == null) {
_logger.printWarning(_codeSignSelectionCanceled);
return;
}
} else if (style == _CodeSigningStyle.manual) {
final List<_ProvisioningProfile> validProvisioningProfiles = await _getProvisioningProfiles();
if (validProvisioningProfiles.isEmpty) {
_logger.printError(
'No provisioning profiles were found. To learn how to create or download '
'a provisioning profile, please see '
'https://developer.apple.com/help/account/manage-provisioning-profiles/create-a-development-provisioning-profile',
emphasis: true,
);
_logger.printWarning(_codeSignSelectionCanceled);
return;
}
final _ProvisioningProfile? profile = await _selectProvisioningProfile(
validProvisioningProfiles,
);
if (profile == null) {
_logger.printWarning(_codeSignSelectionCanceled);
return;
}
_saveProvisioningProfile(profile);
}
}
/// Prompt user to select a code-signing style (Automatic or Manual).
Future<_CodeSigningStyle?> _selectSigningStyle() async {
_logger.printStatus('Code Signing Styles:', emphasis: true);
_logger.printStatus(
' This setting specifies the method used to acquire and locate signing '
'assets. Choose Automatic to let Xcode automatically create and update '
'profiles, app IDs, and certificates. Choose Manual to create and update '
'these yourself on the developer website.',
);
_logger.printStatus('[1]: ${_CodeSigningStyle.automatic.label} (recommended)');
_logger.printStatus('[2]: ${_CodeSigningStyle.manual.label}');
final String choice = await _terminal.promptForCharInput(
<String>['1', '2', 'q'],
prompt: 'Select a signing style (or "q" to quit)',
defaultChoiceIndex: 0, // Just pressing enter chooses the first one.
logger: _logger,
displayAcceptedCharacters: false,
);
return switch (choice) {
'1' => _CodeSigningStyle.automatic,
'2' => _CodeSigningStyle.manual,
_ => null,
};
}
/// Prompts the user to select a code-signing identity from a list of [validCodeSigningIdentities].
/// Selects the first one found without prompting if there is no stdin or if
/// [autoSelectSingle] is true and only one identity was found.
///
/// Saves the selected identity to the config. Does not save if auto-selected.
///
/// Throw an error if [throwOnCancel] is true and the user quits while
/// selecting an identity.
Future<String?> _selectSigningIdentity(
List<String> validCodeSigningIdentities, {
bool autoSelectSingle = false,
bool throwOnCancel = false,
}) async {
if (validCodeSigningIdentities.isEmpty) {
return null;
}
if (autoSelectSingle && validCodeSigningIdentities.length == 1) {
return validCodeSigningIdentities.first;
}
// If terminal UI can't be used, just attempt with the first valid certificate
// since we can't ask the user.
if (!_terminal.stdinHasTerminal) {
return validCodeSigningIdentities.first;
}
_terminal.usesTerminalUi = true;
_logger.printStatus(
'\nValid development certificates available (your choice will be saved):',
emphasis: true,
);
final int count = validCodeSigningIdentities.length;
for (var i = 0; i < count; i++) {
_logger.printStatus('[${i + 1}] ${validCodeSigningIdentities[i]}');
}
final String choice = await _terminal.promptForCharInput(
List<String>.generate(count, (int number) => '${number + 1}')..add('q'),
prompt: 'Please select a certificate for code signing (or "q" to quit)',
defaultChoiceIndex: 0, // Just pressing enter chooses the first one.
logger: _logger,
displayAcceptedCharacters: false,
);
if (choice == 'q') {
if (throwOnCancel) {
throwToolExit(
'No certificate was selected. Code signing is required to build a deployable iOS app.',
);
} else {
return null;
}
}
final String selectedCert = validCodeSigningIdentities[int.parse(choice) - 1];
_saveCodeSignIdentity(selectedCert);
return selectedCert;
}
/// Get list of provisioning profiles from `~/Library/Developer/Xcode/UserData/Provisioning\ Profiles`.
///
/// Only return non-Xcode-managed profiles with matching valid identities.
Future<List<_ProvisioningProfile>> _getProvisioningProfiles() async {
final String? homeDir = _fileSystemUtils.homeDirPath;
if (homeDir == null) {
return <_ProvisioningProfile>[];
}
final Directory profileDirectory = _fileSystem.directory(
_fileSystem.path.join(
homeDir,
'Library',
'Developer',
'Xcode',
'UserData',
'Provisioning Profiles',
),
);
if (!profileDirectory.existsSync()) {
return <_ProvisioningProfile>[];
}
final List<String> validCodeSigningIdentities = await _getSigningIdentities();
final profiles = <_ProvisioningProfile>[];
for (final FileSystemEntity entity in profileDirectory.listSync()) {
if (entity is! File || _fileSystem.path.extension(entity.path) != '.mobileprovision') {
continue;
}
final _ProvisioningProfile? profile = await _parseProvisioningProfile(entity);
// Xcode managed profiles can't be used for manual code-signing.
final bool? isXcodeManaged = profile?.isXcodeManaged;
if (profile == null || (isXcodeManaged != null && isXcodeManaged)) {
continue;
}
// Only list profiles with valid identities.
for (final File cert in profile.developerCertificates) {
if (await _validateIdentityFromCert(cert, validCodeSigningIdentities) != null) {
profiles.add(profile);
break;
}
}
}
return profiles;
}
/// Prompt the user to select from list of [validatedProfiles].
Future<_ProvisioningProfile?> _selectProvisioningProfile(
List<_ProvisioningProfile> validatedProfiles,
) async {
if (validatedProfiles.isEmpty) {
return null;
}
_logger.printStatus(
'\nValid provisioning profiles available (your choice will be saved):',
emphasis: true,
);
var count = 1;
for (final profile in validatedProfiles) {
_logger.printStatus(
'[$count]: ${profile.name} (${profile.teamIdentifier}) | Expires ${profile.expirationDate}',
);
count++;
}
_logger.printStatus('[$count]: Other (not listed)');
final String choice = await _terminal.promptForCharInput(
List<String>.generate(validatedProfiles.length + 1, (int number) => '${number + 1}')
..add('q'),
prompt: 'Select a provisioning profile (or "q" to quit)',
defaultChoiceIndex: 0, // Just pressing enter chooses the first one.
logger: _logger,
displayAcceptedCharacters: false,
);
if (choice == 'q') {
return null;
} else if (choice == '$count') {
_logger.printError(
'If you have already downloaded a provisioning profile, double-click it '
'in Finder to install it. To learn how to create or download a '
'provisioning profile, please see '
'https://developer.apple.com/help/account/manage-provisioning-profiles/create-a-development-provisioning-profile',
);
return null;
}
return validatedProfiles[int.parse(choice) - 1];
}
}
enum _CodeSigningStyle {
automatic('Automatic'),
manual('Manual');
const _CodeSigningStyle(this.label);
final String label;
}
class _ProvisioningProfile {
_ProvisioningProfile({
required this.filePath,
required this.name,
required this.teamIdentifier,
required this.expirationDate,
required this.developerCertificates,
this.isXcodeManaged,
});
factory _ProvisioningProfile.fromPlist(
String filePath,
Map<String, Object> data, {
required FileSystem fileSystem,
}) {
final name = data['Name']?.toString();
if (name == null) {
throw Exception('Unable to parse Name value for provisioning profile.');
}
var identifiers = <String>[];
if (data case {'TeamIdentifier': final List<Object?> values}) {
try {
identifiers = List<String>.from(values);
if (identifiers.isEmpty) {
throw Exception('Unable to parse TeamIdentifier value for provisioning profile.');
}
} on TypeError {
throw Exception('Error parsing TeamIdentifier value: $values');
}
}
final uuid = data['UUID']?.toString();
if (uuid == null) {
throw Exception('Unable to parse UUID value for provisioning profile.');
}
final certificateFiles = <File>[];
if (data case {'DeveloperCertificates': final List<Object?> values}) {
for (var i = 0; i < values.length; i++) {
final Object? obj = values[i];
if (obj != null && obj is List<int>) {
final File certFile = fileSystem.systemTempDirectory
.childDirectory('provisioning_profile_certificates')
.childFile('${uuid}_$i.cer');
certFile.createSync(recursive: true);
certFile.writeAsBytesSync(obj);
certificateFiles.add(certFile);
}
}
}
if (certificateFiles.isEmpty) {
throw Exception('Unable to parse DeveloperCertificates value for provisioning profile.');
}
final expirationDateString = data['ExpirationDate']?.toString();
if (expirationDateString == null) {
throw Exception('Unable to parse ExpirationDate value for provisioning profile.');
}
final DateTime expirationDate = DateTime.parse(expirationDateString);
return _ProvisioningProfile(
filePath: filePath,
name: name,
developerCertificates: certificateFiles,
isXcodeManaged: data['IsXcodeManaged'] is bool? ? data['IsXcodeManaged'] as bool? : null,
expirationDate: expirationDate,
teamIdentifier: identifiers.first,
);
}
final String filePath;
final String name;
final String teamIdentifier;
final DateTime expirationDate;
final List<File> developerCertificates;
final bool? isXcodeManaged;
}
/// Returns true if s is a not empty string.
bool _isNotEmpty(String? s) => s != null && s.isNotEmpty;