install static method
Future<AppleSigningSession>
install(
- AppleResolvedSigningAssets assets,
- String bundleId, {
- ProcessRunner processRunner = const SystemProcessRunner(),
- bool? isMacOS,
- String? homeDirectory,
- Directory? temporaryRoot,
Installs signing assets for one bundle and its extensions.
Implementation
static Future<AppleSigningSession> install(
AppleResolvedSigningAssets assets,
String bundleId, {
ProcessRunner processRunner = const SystemProcessRunner(),
bool? isMacOS,
String? homeDirectory,
Directory? temporaryRoot,
}) async {
if (!(isMacOS ?? Platform.isMacOS)) {
throw const SmfError(
'iOS signing requires a macOS runner.',
SmfErrorCode.macosRequired,
);
}
final parent = temporaryRoot ?? Directory.systemTemp;
final credentials = assets.credentials;
final temporaryDirectory = await parent.createTemp('smf-signing-');
final keychainPath = p.join(temporaryDirectory.path, 'signing.keychain-db');
final keychainPassword = _randomToken(32);
final certificatePath = p.join(temporaryDirectory.path, 'distribution.p12');
final home = p.normalize(
p.absolute(
homeDirectory ?? Platform.environment['HOME'] ?? Directory.current.path,
),
);
final profilesDirectory = p.join(
home,
'Library',
'MobileDevice',
'Provisioning Profiles',
);
final installedPaths = <String>[];
List<String>? previousKeychains;
var wasKeychainCreated = false;
var wasInstallationCompleted = false;
Future<void> cleanup() async {
for (final installedPath in installedPaths) {
await _removeIfExists(installedPath);
}
final keychainsToRestore = previousKeychains;
if (keychainsToRestore != null) {
await processRunner.run('security', <String>[
'list-keychains',
'-d',
'user',
'-s',
...keychainsToRestore,
], options: const RunOptions(isFailureAllowed: true));
}
if (wasKeychainCreated) {
await processRunner.run('security', <String>[
'delete-keychain',
keychainPath,
], options: const RunOptions(isFailureAllowed: true));
}
try {
await temporaryDirectory.delete(recursive: true);
} on FileSystemException {
// Best effort.
}
}
try {
await _rejectSymbolicLinks(
home,
profilesDirectory,
description: 'The provisioning-profile directory',
code: SmfErrorCode.profileCollision,
);
await File(certificatePath).writeAsBytes(
_decodeBase64(
credentials.certificateBase64,
'The iOS distribution certificate',
SmfErrorCode.invalidCertificate,
),
);
await processRunner.run('/bin/chmod', <String>['600', certificatePath]);
wasKeychainCreated = true;
await processRunner.run('security', <String>[
'create-keychain',
'-p',
keychainPassword,
keychainPath,
]);
await processRunner.run('security', <String>[
'set-keychain-settings',
'-lut',
'21600',
keychainPath,
]);
await processRunner.run('security', <String>[
'unlock-keychain',
'-p',
keychainPassword,
keychainPath,
]);
await processRunner.run('security', <String>[
'import',
certificatePath,
'-P',
credentials.certificatePassword,
'-A',
'-t',
'cert',
'-f',
'pkcs12',
'-k',
keychainPath,
]);
await processRunner.run('security', <String>[
'set-key-partition-list',
'-S',
'apple-tool:,apple:,codesign:',
'-s',
'-k',
keychainPassword,
keychainPath,
]);
final existingKeychains = await processRunner.run(
'security',
const <String>['list-keychains', '-d', 'user'],
);
previousKeychains = existingKeychains.stdout
.split('\n')
.map((line) => line.trim().replaceAll(RegExp(r'^"|"$'), ''))
.where((line) => line.isNotEmpty)
.toList();
await processRunner.run('security', <String>[
'list-keychains',
'-d',
'user',
'-s',
keychainPath,
...previousKeychains,
]);
final profileInput = assets.profilesByBundleId;
SmfError.check(
profileInput.containsKey(bundleId),
'Apple signing data is missing the main bundle ID $bundleId.',
SmfErrorCode.signingTargetMissing,
);
await Directory(profilesDirectory).create(recursive: true);
await _rejectSymbolicLinks(
home,
profilesDirectory,
description: 'The provisioning-profile directory',
code: SmfErrorCode.profileCollision,
);
await processRunner.run('/bin/chmod', <String>[
'700',
profilesDirectory,
]);
final profiles = <AppleInstalledProfile>[];
for (final entry in profileInput.entries) {
final sourcePath = p.join(
temporaryDirectory.path,
'${_randomToken(16)}.mobileprovision',
);
await File(sourcePath).writeAsBytes(
_decodeBase64(
entry.value,
'Provisioning profile for ${entry.key}',
SmfErrorCode.invalidProfile,
),
);
await processRunner.run('/bin/chmod', <String>['600', sourcePath]);
final profile = await _decodeProfile(sourcePath, processRunner);
final entitlements = profile['Entitlements'];
final teamIdentifiers = profile['TeamIdentifier'];
if (entitlements is! Map<String, Object?> || teamIdentifiers is! List<Object?> || teamIdentifiers.length != 1) {
throw const SmfError(
'Provisioning profile signing identity is incomplete.',
SmfErrorCode.invalidProfile,
);
}
final applicationIdentifier = entitlements['application-identifier'];
final teamId = teamIdentifiers.first;
if (applicationIdentifier is! String ||
teamId is! String ||
teamId.isEmpty ||
!applicationIdentifier.startsWith('$teamId.')) {
throw const SmfError(
'Provisioning profile signing identity is invalid.',
SmfErrorCode.invalidProfile,
);
}
final actualBundleId = applicationIdentifier.substring(teamId.length + 1);
final profileName = profile['Name'];
final uuid = profile['UUID'];
final expirationDate = profile['ExpirationDate'];
if (profileName is! String || profileName.isEmpty || uuid is! String || expirationDate is! DateTime) {
throw const SmfError(
'Provisioning profile name, UUID, or expiration is missing.',
SmfErrorCode.invalidProfile,
);
}
SmfError.check(
RegExp(
r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$',
).hasMatch(uuid),
'Provisioning profile "$profileName" has an invalid UUID.',
SmfErrorCode.invalidProfile,
);
SmfError.check(
expirationDate.isAfter(DateTime.now().toUtc()),
'Provisioning profile "$profileName" has expired.',
SmfErrorCode.invalidProfile,
);
SmfError.check(
actualBundleId == entry.key,
'Provisioning profile "$profileName" is for $actualBundleId, not '
'${entry.key}.',
SmfErrorCode.profileBundleIdMismatch,
);
final installedPath = p.join(profilesDirectory, '$uuid.mobileprovision');
await _rejectSymbolicLinks(
home,
installedPath,
description: 'The provisioning-profile path',
code: SmfErrorCode.profileCollision,
);
if (await File(installedPath).exists()) {
SmfError.check(
_bytesEqual(
await File(installedPath).readAsBytes(),
await File(sourcePath).readAsBytes(),
),
'A different provisioning profile already exists at $installedPath.',
SmfErrorCode.profileCollision,
);
} else {
await File(sourcePath).copy(installedPath);
await processRunner.run('/bin/chmod', <String>[
'600',
installedPath,
]);
installedPaths.add(installedPath);
}
profiles.add(
AppleInstalledProfile(
bundleId: entry.key,
uuid: uuid,
name: profileName,
teamId: teamId,
installedPath: installedPath,
),
);
}
SmfError.check(profiles.isNotEmpty, 'No provisioning profiles supplied.');
SmfError.check(
profiles.map((profile) => profile.teamId).toSet().length == 1,
'All provisioning profiles must belong to the same Apple team.',
SmfErrorCode.profileTeamMismatch,
);
final exportOptionsPath = p.join(
temporaryDirectory.path,
'ExportOptions.plist',
);
await File(exportOptionsPath).writeAsString(_exportOptionsPlist(profiles));
final session = AppleSigningSession(
keychainPath: keychainPath,
keychainPassword: keychainPassword,
profiles: List<AppleInstalledProfile>.unmodifiable(profiles),
exportOptionsPath: exportOptionsPath,
cleanup: cleanup,
);
wasInstallationCompleted = true;
return session;
} finally {
if (!wasInstallationCompleted) {
await cleanup();
}
}
}