mirror of
https://github.com/xtool-org/xtool.git
synced 2026-02-04 11:53:30 +01:00
This pull request adds support for Apple's [App Extensions](https://developer.apple.com/app-extensions/). Closes: #45 --------- Co-authored-by: ErrorErrorError <16653389+ErrorErrorError@users.noreply.github.com> Co-authored-by: Kabir Oberai <oberai.kabir@gmail.com>
262 lines
10 KiB
Swift
262 lines
10 KiB
Swift
//
|
|
// DeveloperServicesAddAppOperation.swift
|
|
// XKit
|
|
//
|
|
// Created by Kabir Oberai on 14/10/19.
|
|
// Copyright © 2019 Kabir Oberai. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import DeveloperAPI
|
|
|
|
public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation {
|
|
|
|
public enum Error: LocalizedError {
|
|
case invalidApp(URL)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidApp(let url):
|
|
return url.path.withCString {
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString(
|
|
"add_app_operation.error.invalid_app", value: "Invalid app: %s", comment: ""
|
|
), $0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 when using ds2 API
|
|
capability: .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 context.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 }
|
|
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 }
|
|
}
|
|
}
|
|
|
|
}
|