mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-15 12:04:55 +03:00
64fe8e30b3
## What does this PR do? Replaces zsign with the [apple-codesign](https://github.com/indygreg/apple-platform-rs/tree/0ebbd2ef3b96d5adccc30b1cad4deea38cd735c4/apple-codesign) Rust crate. It's much more usable as a library (while zsign has bare prints all over the place + we had to fork it to maintain a public interface), it supports macOS-style bundles, and also has other features that we would've needed to further patch zsign for. ## How was it tested? - [x] Ran an ad-hoc signed app on the simulator (with a subsequent PR) - [x] Ran an app with App Groups on device, confirmed that the app group is present ## AI tool usage How much of this PR was AI-assisted? (check one) - [ ] **0** - No AI was used to write code - [x] **1** - I was assisted by AI. I reviewed the finished result. - [ ] **2** - I set the AI going and left it to it; nobody has read the result - no review, or AI review only <!-- If an AI agent is filling this in: declare the level honestly, and open as a draft if it is #2. Do not lower the declared level to get the PR reviewed. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Updated Signing** * App signing now uses an updated signing engine and supports certificate-based and ad hoc signing. * Entitlement data, including data provided through entitlement files, is passed through during signing. * Deep signing is enabled in the development command. * **Validation** * Signing rejects invalid app or entitlement file paths and duplicate entitlement paths. * **Compatibility** * Signing contexts can be created without selecting a signer. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
273 lines
11 KiB
Swift
273 lines
11 KiB
Swift
//
|
|
// DeveloperServicesAddAppOperation.swift
|
|
// XKit
|
|
//
|
|
// Created by Kabir Oberai on 14/10/19.
|
|
// Copyright © 2019 Kabir Oberai. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import DeveloperAPI
|
|
import Dependencies
|
|
|
|
public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation {
|
|
|
|
@Dependency(\.signer) private var signer
|
|
|
|
public enum Error: LocalizedError, CustomStringConvertible {
|
|
case invalidApp(URL)
|
|
case tooManyAppGroups(bundleID: String, groups: [DeveloperServicesAppGroup.GroupID])
|
|
|
|
public var errorDescription: String? {
|
|
description
|
|
}
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .invalidApp(let url):
|
|
"Invalid app: \(url.path)"
|
|
case .tooManyAppGroups(let bundleID, let groups):
|
|
"""
|
|
App '\(bundleID)' requests multiple app groups: \(groups.map(\.rawValue).joined(separator: ", ")), \
|
|
which is not permitted when using a free team.
|
|
"""
|
|
}
|
|
}
|
|
}
|
|
|
|
public let context: SigningContext
|
|
public let signingInfo: SigningInfo
|
|
public let root: URL
|
|
public init(context: SigningContext, signingInfo: SigningInfo, root: URL) {
|
|
self.context = context
|
|
self.signingInfo = signingInfo
|
|
self.root = root
|
|
}
|
|
|
|
/// Registers the app with the given entitlements
|
|
private func upsertApp(
|
|
bundleID: String,
|
|
entitlements: Entitlements,
|
|
isFreeTeam: Bool
|
|
) async throws -> Components.Schemas.BundleId {
|
|
let newBundleID = ProvisioningIdentifiers.identifier(fromSanitized: bundleID, context: self.context)
|
|
|
|
let existing = try await context.developerAPIClient
|
|
.bundleIdsGetCollection(query: .init(
|
|
filter_lbrack_identifier_rbrack_: [bundleID]
|
|
))
|
|
.ok.body.json.data
|
|
// filter[identifier] is a prefix filter so we need to manually upgrade to equality
|
|
.first(where: { $0.attributes?.identifier == newBundleID })
|
|
|
|
let appID: Components.Schemas.BundleId
|
|
if let existing {
|
|
appID = existing
|
|
} else {
|
|
let name = ProvisioningIdentifiers.appName(fromSanitized: bundleID)
|
|
let createResponse = try await context.developerAPIClient.bundleIdsCreateInstance(
|
|
body: .json(
|
|
.init(
|
|
data: .init(
|
|
_type: .bundleIds,
|
|
attributes: .init(
|
|
name: name,
|
|
platform: .init(.ios),
|
|
identifier: newBundleID
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
appID = try createResponse.created.body.json.data
|
|
}
|
|
|
|
let existingCapabilitiesList = try await context.developerAPIClient
|
|
.bundleIdsBundleIdCapabilitiesGetToManyRelated(.init(path: .init(id: appID.id)))
|
|
.ok.body.json.data
|
|
let existingCapabilities = [Components.Schemas.CapabilityType: Components.Schemas.BundleIdCapability](
|
|
existingCapabilitiesList.compactMap { cap in
|
|
(cap.attributes?.capabilityType).map { ($0, cap) }
|
|
},
|
|
uniquingKeysWith: { $1 }
|
|
)
|
|
|
|
let wantedCapabilitiesList = try entitlements.entitlements().compactMap(\.anyCapability)
|
|
let wantedCapabilities = [Components.Schemas.CapabilityType: [Components.Schemas.CapabilitySetting]](
|
|
wantedCapabilitiesList.map { ($0.capabilityType, $0.settings ?? []) },
|
|
uniquingKeysWith: { $1 }
|
|
)
|
|
|
|
for (typ, cap) in existingCapabilities {
|
|
if let wantedSettings = wantedCapabilities[typ] {
|
|
if wantedSettings != (cap.attributes?.settings ?? []) {
|
|
_ = try await context.developerAPIClient.bundleIdCapabilitiesUpdateInstance(
|
|
path: .init(id: cap.id),
|
|
body: .json(
|
|
.init(
|
|
data: .init(
|
|
_type: .bundleIdCapabilities,
|
|
id: cap.id,
|
|
attributes: .init(
|
|
capabilityType: typ,
|
|
settings: wantedSettings
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.ok
|
|
}
|
|
} else {
|
|
// DeveloperServices doesn't allow deleting these capabilities
|
|
let requiredCapabilities: Set<Components.Schemas.CapabilityType.Value1Payload> = [.inAppPurchase]
|
|
if let capType = cap.attributes?.capabilityType?.value1, !requiredCapabilities.contains(capType) {
|
|
_ = try await context.developerAPIClient
|
|
.bundleIdCapabilitiesDeleteInstance(path: .init(id: cap.id))
|
|
.noContent
|
|
}
|
|
}
|
|
}
|
|
for (typ, settings) in wantedCapabilities {
|
|
guard existingCapabilities[typ] == nil else { continue }
|
|
_ = try await context.developerAPIClient.bundleIdCapabilitiesCreateInstance(
|
|
body: .json(.init(data: .init(
|
|
_type: .bundleIdCapabilities,
|
|
attributes: .init(
|
|
capabilityType: typ,
|
|
settings: settings
|
|
),
|
|
relationships: .init(
|
|
bundleId: .init(
|
|
data: .init(
|
|
_type: .bundleIds,
|
|
id: appID.id
|
|
)
|
|
),
|
|
// not public but required iff using ds2 API
|
|
capability: context.auth.isPublicAPI ? nil : .init(
|
|
data: .init(
|
|
_type: .capabilities,
|
|
id: typ
|
|
)
|
|
)
|
|
)
|
|
)))
|
|
)
|
|
.created.body
|
|
}
|
|
|
|
return appID
|
|
}
|
|
|
|
/// Registers the app and creates a profile. Returns the resultant entitlements as well as
|
|
/// the profile (note that the profile does not necessarily include all of the entitlements
|
|
/// that the app has).
|
|
private func addApp(
|
|
_ app: URL,
|
|
isFreeTeam: Bool
|
|
) async throws -> ProvisioningInfo {
|
|
let infoURL = app.appendingPathComponent("Info.plist")
|
|
guard let data = try? Data(contentsOf: infoURL),
|
|
let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
|
|
let bundleID = dict["CFBundleIdentifier"] as? String,
|
|
let executable = dict["CFBundleExecutable"] as? String else {
|
|
throw Error.invalidApp(app)
|
|
}
|
|
let executableURL = app.appendingPathComponent(executable)
|
|
|
|
var entitlements: Entitlements
|
|
// if the executable doesn't already have entitlements, that's
|
|
// okay. We don't have to throw an error.
|
|
if let entitlementsData = try? await signer.analyze(executable: executableURL),
|
|
let decodedEntitlements = try? PropertyListDecoder().decode(Entitlements.self, from: entitlementsData) {
|
|
entitlements = decodedEntitlements
|
|
|
|
if isFreeTeam {
|
|
// re-assign rather than using updateEntitlements since the latter will
|
|
// retain unrecognized entitlements
|
|
let filtered = try entitlements.entitlements().filter { $0.anyCapability?.isFree != false }
|
|
if let groups = filtered.lazy.compactMap({ $0 as? AppGroupEntitlement }).first?.rawValue,
|
|
groups.count > 1 {
|
|
throw Error.tooManyAppGroups(bundleID: bundleID, groups: groups)
|
|
}
|
|
entitlements = try Entitlements(entitlements: filtered)
|
|
}
|
|
} else {
|
|
entitlements = try Entitlements(entitlements: [])
|
|
}
|
|
|
|
let appID = try await upsertApp(bundleID: bundleID, entitlements: entitlements, isFreeTeam: isFreeTeam)
|
|
let newBundleID = appID.attributes!.identifier!
|
|
|
|
let teamID = try signingInfo.certificate.teamID()
|
|
try entitlements.update(
|
|
teamID: .init(rawValue: teamID),
|
|
bundleID: newBundleID
|
|
)
|
|
// set get-task-allow to YES, required for dev certs
|
|
try entitlements.updateEntitlements { ents in
|
|
if let getTaskAllow = ents.firstIndex(where: { $0 is GetTaskAllowEntitlement }) {
|
|
ents[getTaskAllow] = GetTaskAllowEntitlement(rawValue: true)
|
|
} else {
|
|
ents.append(GetTaskAllowEntitlement(rawValue: true))
|
|
}
|
|
}
|
|
|
|
if var entitlementsArray = try? entitlements.entitlements(),
|
|
let groupsIdx = entitlementsArray.firstIndex(where: { $0 is AppGroupEntitlement }),
|
|
let groupsEntitlement = entitlementsArray[groupsIdx] as? AppGroupEntitlement {
|
|
let groups = groupsEntitlement.rawValue
|
|
|
|
if let operation = DeveloperServicesAssignAppGroupsOperation(
|
|
context: self.context,
|
|
groupIDs: groups,
|
|
appID: appID
|
|
) {
|
|
let newGroups = try await operation.perform()
|
|
entitlementsArray[groupsIdx] = AppGroupEntitlement(rawValue: newGroups)
|
|
try entitlements.setEntitlements(entitlementsArray)
|
|
}
|
|
}
|
|
|
|
let mobileprovision = try await DeveloperServicesFetchProfileOperation(
|
|
context: self.context,
|
|
bundleID: newBundleID,
|
|
signingInfo: signingInfo
|
|
).perform()
|
|
|
|
return ProvisioningInfo(
|
|
newBundleID: newBundleID,
|
|
entitlements: entitlements,
|
|
mobileprovision: mobileprovision
|
|
)
|
|
}
|
|
|
|
/// Registers the app + its extensions, returning the profile and entitlements of each
|
|
public func perform() async throws -> [URL: ProvisioningInfo] {
|
|
var apps: [URL] = [root]
|
|
|
|
for pluginsDir in ["PlugIns", "Extensions"] {
|
|
let plugins = root.appendingPathComponent(pluginsDir)
|
|
guard plugins.dirExists else { continue }
|
|
apps += plugins.implicitContents.filter { $0.pathExtension.lowercased() == "appex" }
|
|
}
|
|
|
|
let isFreeTeam = try await context.auth.team()?.isFree == true
|
|
|
|
return try await withThrowingTaskGroup(
|
|
of: (URL, ProvisioningInfo).self,
|
|
returning: [URL: ProvisioningInfo].self
|
|
) { group in
|
|
for app in apps {
|
|
group.addTask {
|
|
let info = try await addApp(app, isFreeTeam: isFreeTeam)
|
|
return (app, info)
|
|
}
|
|
}
|
|
return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
|
|
}
|
|
}
|
|
|
|
}
|