install static method

Future<AppleSigningSession> install(
  1. AppleResolvedSigningAssets assets,
  2. String bundleId, {
  3. ProcessRunner processRunner = const SystemProcessRunner(),
  4. bool? isMacOS,
  5. String? homeDirectory,
  6. 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();
    }
  }
}