diff --git a/Supersign/Package.swift b/Supersign/Package.swift index 4c35b13..976d9db 100644 --- a/Supersign/Package.swift +++ b/Supersign/Package.swift @@ -20,16 +20,11 @@ let package = Package( type: .dynamic, targets: ["Supersign"] ), - .executable( - name: "SupersignCLI", - targets: ["SupersignCLI"] - ) ], dependencies: [ .package(path: "../SuperchargeCore"), .package(path: "../SwiftyMobileDevice"), .package(path: "../USBMuxSim"), - .package(path: "../SupersignLdid"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.0.0"), ], targets: [ @@ -72,17 +67,5 @@ let package = Package( .copy("config/test.app"), ] ), - .target( - name: "SupersignCLI", - dependencies: [ - "SwiftyMobileDevice", - "Supersign", - "SupersignLdid" - ], - resources: [ - .copy("Supercharge.ipa") - ], - cSettings: cSettings - ) ] ) diff --git a/Supersign/Sources/Supersign/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift b/Supersign/Sources/Supersign/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift index 0abc6a1..74b77ac 100644 --- a/Supersign/Sources/Supersign/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift +++ b/Supersign/Sources/Supersign/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift @@ -12,6 +12,7 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera public enum Error: LocalizedError { case csrFailed + case userCancelled public var errorDescription: String? { switch self { @@ -19,13 +20,22 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera return NSLocalizedString( "fetch_certificate_operation.error.csr_failed", value: "CSR request failed", comment: "" ) + case .userCancelled: + return NSLocalizedString( + "fetch_certificate_operation.error.user_cancelled", value: "The operation was cancelled", comment: "" + ) } } } public let context: SigningContext - public init(context: SigningContext) { + public let confirmRevocation: ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void + public init( + context: SigningContext, + confirmRevocation: @escaping ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void + ) { self.context = context + self.confirmRevocation = confirmRevocation } private func createCertificate( @@ -78,14 +88,17 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera certificates: [DeveloperServicesCertificate], completion: @escaping (Result) -> Void ) { - let grouper = RequestGrouper() - certificates.forEach { - let request = DeveloperServicesRevokeCertificateRequest(teamID: context.teamID, certificateID: $0.id) - grouper.add { context.client.send(request, completion: $0) } - } - grouper.onComplete { result in - guard result.get(withErrorHandler: completion) != nil else { return } - self.createAndSaveCertificate(completion: completion) + confirmRevocation(certificates) { confirmation in + guard confirmation else { return completion(.failure(Error.userCancelled)) } + let grouper = RequestGrouper() + certificates.forEach { + let request = DeveloperServicesRevokeCertificateRequest(teamID: context.teamID, certificateID: $0.id) + grouper.add { context.client.send(request, completion: $0) } + } + grouper.onComplete { result in + guard result.get(withErrorHandler: completion) != nil else { return } + self.createAndSaveCertificate(completion: completion) + } } } diff --git a/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesPlatformRequest.swift b/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesPlatformRequest.swift index febf154..e186918 100644 --- a/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesPlatformRequest.swift +++ b/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesPlatformRequest.swift @@ -8,18 +8,38 @@ import Foundation -public enum DeveloperServicesPlatform: Decodable, CaseIterable { - case unknown +public enum DeveloperServicesPlatform: Decodable, Hashable { + private static let known: ([String: DeveloperServicesPlatform], [DeveloperServicesPlatform: String]) = { + var first: [String: DeveloperServicesPlatform] = [:] + var second: [DeveloperServicesPlatform: String] = [:] + func add(_ platform: DeveloperServicesPlatform, _ name: String? = nil) { + let name = name ?? "\(platform)" + first[name] = platform + second[platform] = name + } + + add(.macOS, "mac") + add(.iOS, "ios") + add(.watchOS, "watchOS") + add(.tvOS, "tvOS") + add(.safari) + + return (first, second) + }() + + case macOS case iOS case watchOS case tvOS + case safari + case unknown(String) var rawValue: String { switch self { - case .iOS: return "ios" - case .watchOS: return "watchos" - case .tvOS: return "tvos" - case .unknown: return "" + case .unknown(let val): + return val + default: + return Self.known.1[self] ?? "\(self)" } } @@ -27,7 +47,7 @@ public enum DeveloperServicesPlatform: Decodable, CaseIterable { switch self { case .iOS, .watchOS: return "ios" case .tvOS: return "tvos" - case .unknown: return "" + default: return "" } } @@ -35,7 +55,7 @@ public enum DeveloperServicesPlatform: Decodable, CaseIterable { switch self { case .iOS, .watchOS: return nil case .tvOS: return "tvOS" - case .unknown: return "" + default: return "" } } @@ -51,7 +71,7 @@ public enum DeveloperServicesPlatform: Decodable, CaseIterable { public init(from decoder: Decoder) throws { let rawValue = try String(from: decoder) - self = Self.allCases.first { $0.rawValue == rawValue } ?? .unknown + self = Self.known.0[rawValue] ?? .unknown(rawValue) } } diff --git a/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesProvisioningOperation.swift b/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesProvisioningOperation.swift index 206cb45..dcde4ce 100644 --- a/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesProvisioningOperation.swift +++ b/Supersign/Sources/Supersign/DeveloperServices/DeveloperServicesProvisioningOperation.swift @@ -23,10 +23,17 @@ public struct DeveloperServicesProvisioningOperation: DeveloperServicesOperation public let context: SigningContext public let app: URL + public let confirmRevocation: ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void public let progress: (Double) -> Void - public init(context: SigningContext, app: URL, progress: @escaping (Double) -> Void) { + public init( + context: SigningContext, + app: URL, + confirmRevocation: @escaping ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void, + progress: @escaping (Double) -> Void + ) { self.context = context self.app = app + self.confirmRevocation = confirmRevocation self.progress = progress } @@ -35,7 +42,10 @@ public struct DeveloperServicesProvisioningOperation: DeveloperServicesOperation DeveloperServicesFetchDeviceOperation(context: context).perform { result in guard result.get(withErrorHandler: completion) != nil else { return } self.progress(1/3) - DeveloperServicesFetchCertificateOperation(context: self.context).perform { result in + DeveloperServicesFetchCertificateOperation( + context: self.context, + confirmRevocation: confirmRevocation + ).perform { result in guard let signingInfo = result.get(withErrorHandler: completion) else { return } self.progress(2/3) DeveloperServicesAddAppOperation(context: self.context, root: self.app).perform { result in diff --git a/Supersign/Sources/Supersign/HTTPClientProtocol/AsyncHTTPClient+HTTP.swift b/Supersign/Sources/Supersign/HTTPClientProtocol/AsyncHTTPClient+HTTP.swift index 7354eab..da9e3b6 100644 --- a/Supersign/Sources/Supersign/HTTPClientProtocol/AsyncHTTPClient+HTTP.swift +++ b/Supersign/Sources/Supersign/HTTPClientProtocol/AsyncHTTPClient+HTTP.swift @@ -125,8 +125,10 @@ final class AsyncHTTPClientFactory: HTTPClientFactory { private init() { // if ssl cert parsing fails we're screwed so we might as well force try let appleRootCA = try! NIOSSLCertificate(bytes: Array(Self.appleRootPEM.utf8), format: .pem) + var tlsConfiguration: TLSConfiguration = .makeClientConfiguration() + tlsConfiguration.additionalTrustRoots = [.certificates([appleRootCA])] let config = HTTPClient.Configuration( - tlsConfiguration: .forClient(additionalTrustRoots: [.certificates([appleRootCA])]), + tlsConfiguration: tlsConfiguration, decompression: .enabled(limit: .none) ) client = HTTPClient(eventLoopGroupProvider: .createNew, configuration: config) diff --git a/Supersign/Sources/Supersign/Installation/AppInstaller.swift b/Supersign/Sources/Supersign/Installation/AppInstaller.swift index 41bb00a..f1ca91f 100644 --- a/Supersign/Sources/Supersign/Installation/AppInstaller.swift +++ b/Supersign/Sources/Supersign/Installation/AppInstaller.swift @@ -88,30 +88,26 @@ public final class AppInstaller { } } - public let app: URL + public let ipa: URL public let udid: String - public let pairingKeys: PairingKeys + public let connectionPreferences: Connection.Preferences public init( - app: URL, + ipa: URL, udid: String, - pairingKeys: PairingKeys + connectionPreferences: Connection.Preferences ) { - self.app = app + self.ipa = ipa self.udid = udid - self.pairingKeys = pairingKeys + self.connectionPreferences = connectionPreferences } // synchronous - private func installSync( - connectionPreferences: Connection.Preferences, - progress: @escaping (Stage) -> Void - ) throws { + private func installSync(progress: @escaping (Stage) -> Void) throws { defer { needsCancellation = false } try cancelPoint() let connection = try Connection.connection( forUDID: udid, - pairingKeys: pairingKeys, preferences: connectionPreferences ) { progress(.connecting($0 * 4/6)) @@ -131,7 +127,7 @@ public final class AppInstaller { try cancelPoint() - let uploaded = try uploader.upload(app: app) { currentProgress in + let uploaded = try uploader.upload(app: ipa) { currentProgress in progress(.uploading(currentProgress)) } defer { uploaded.delete() } @@ -146,16 +142,12 @@ public final class AppInstaller { } public func install( - connectionPreferences: Connection.Preferences = .init(), progress: @escaping (Stage) -> Void, completion: @escaping (Result<(), Swift.Error>) -> Void ) { installQueue.async { completion(Result { - try self.installSync( - connectionPreferences: connectionPreferences, - progress: progress - ) + try self.installSync(progress: progress) }) } } diff --git a/Supersign/Sources/Supersign/Installation/Connection.swift b/Supersign/Sources/Supersign/Installation/Connection.swift index 37e9e01..e8626f9 100644 --- a/Supersign/Sources/Supersign/Installation/Connection.swift +++ b/Supersign/Sources/Supersign/Installation/Connection.swift @@ -13,8 +13,6 @@ import SwiftyMobileDevice import USBMuxSim import PortForwarding -public typealias PairingKeys = Data - private extension USBMuxSimulator { func performAtomically(_ operation: (USBMuxSimulator) -> T) -> T { var value: T! @@ -30,8 +28,6 @@ private extension USBMuxSimulator { return try value.get() } } -#else -public typealias PairingKeys = Void #endif private class WeakBox { @@ -39,25 +35,39 @@ private class WeakBox { init(_ value: T) { self.value = value } } -// there is guaranteed to be at most one Connection instance per udid +// there is guaranteed to be at most one Connection instance per (udid, preferences) // at any moment, thanks to Connection.connection(...). Consequently -// there is at most one device per udid at any moment too. +// there is at most one device per (udid, preferences) at any moment too. public class Connection { public enum Error: Swift.Error { case portForwardingFailed } - public struct Preferences { - public let usePortForwarding: Bool - public init(usePortForwarding: Bool = false) { + public struct Preferences: Hashable { + #if os(iOS) + public var pairingKeys: Data + public var usePortForwarding: Bool + public init(pairingKeys: Data, usePortForwarding: Bool = false) { + self.pairingKeys = pairingKeys self.usePortForwarding = usePortForwarding } + #else + public var lookupMode: LookupMode + public init(lookupMode: LookupMode) { + self.lookupMode = lookupMode + } + #endif + } + + private struct ConnectionDescriptor: Hashable { + let udid: String + let preferences: Preferences } private static let label = "supersign" - private static var connections: [String: WeakBox] = [:] + private static var connections: [ConnectionDescriptor: WeakBox] = [:] private static let connectionsQueue = DispatchQueue(label: "connections-queue") #if os(iOS) @@ -67,11 +77,10 @@ public class Connection { private let udid: String public let device: Device public let client: LockdownClient - public var preferences: Preferences + public let preferences: Preferences private init( udid: String, - pairingKeys: PairingKeys, preferences: Preferences, progress: (Double) -> Void ) throws { @@ -93,42 +102,44 @@ public class Connection { NSLog("[SuperUSB] %@", "Using VPN device") simulatedDevice = try VPNDevice(udid: udid) } - simulatedDevice.pairingKeys = pairingKeys + simulatedDevice.pairingKeys = preferences.pairingKeys handle = USBMuxSimulator.shared.performAtomically { $0.register(device: simulatedDevice) } - #endif progress(1/4) device = try Device(udid: udid) + #else + device = try Device(udid: udid, lookupMode: preferences.lookupMode) + #endif progress(2/4) client = try LockdownClient(device: device, label: Self.label, performHandshake: true) - progress(3/4) #if os(iOS) + progress(3/4) heartbeatHandler = HeartbeatHandler(device: device, client: client) #endif + progress(4/4) } public static func connection( forUDID udid: String, - pairingKeys: PairingKeys, - preferences: Preferences = .init(), + preferences: Preferences, progress: (Double) -> Void ) throws -> Connection { - try connectionsQueue.sync { + let descriptor = ConnectionDescriptor(udid: udid, preferences: preferences) + return try connectionsQueue.sync { progress(0) - if let conn = connections[udid]?.value { + if let conn = connections[descriptor]?.value { progress(1) return conn } let conn = try Connection( udid: udid, - pairingKeys: pairingKeys, preferences: preferences, progress: progress ) - connections[udid] = WeakBox(conn) + connections[descriptor] = WeakBox(conn) return conn } } diff --git a/Supersign/Sources/Supersign/Installation/ConnectionManager.swift b/Supersign/Sources/Supersign/Installation/ConnectionManager.swift index 493e66e..7a06208 100644 --- a/Supersign/Sources/Supersign/Installation/ConnectionManager.swift +++ b/Supersign/Sources/Supersign/Installation/ConnectionManager.swift @@ -15,11 +15,28 @@ public protocol ConnectionManagerDelegate: AnyObject { public class ConnectionManager { + public enum SearchMode: CaseIterable { + case usb + case network + case all + + func allows(_ connectionType: ConnectionType) -> Bool { + switch self { + case .usb: + return connectionType == .usb + case .network: + return connectionType == .network + case .all: + return true + } + } + } + fileprivate struct ConnectionKey: Hashable, Equatable, Comparable { let udid: String - let connectionType: USBMux.ConnectionType + let connectionType: ConnectionType - private static func precedence(for connectionType: USBMux.ConnectionType) -> Int { + private static func precedence(for connectionType: ConnectionType) -> Int { switch connectionType { case .network: return 0 case .usb: return 1 @@ -38,7 +55,7 @@ public class ConnectionManager { let deviceName: String init(key: ConnectionKey) throws { - let deviceConnectionType: Device.ConnectionType + let deviceConnectionType: ConnectionType switch key.connectionType { case .network: deviceConnectionType = .network @@ -63,7 +80,7 @@ public class ConnectionManager { private let value: ConnectionValue public var udid: String { key.udid } - public var connectionType: USBMux.ConnectionType { key.connectionType } + public var connectionType: ConnectionType { key.connectionType } public var device: Device { value.device } public var deviceName: String { value.deviceName } @@ -73,7 +90,7 @@ public class ConnectionManager { } } - private var clientsDict: [ConnectionKey: ConnectionValue] = [:] { + private var clientsDict: [ConnectionKey: ConnectionValue] { didSet { clientsDidChange() } @@ -87,17 +104,27 @@ public class ConnectionManager { private var token: USBMux.SubscriptionToken? - public let usbOnly: Bool + public let searchMode: SearchMode public private(set) weak var delegate: ConnectionManagerDelegate? private func clientsDidChange() { clients = clientsDict.sorted { $0.0 < $1.0 }.map(Client.init) } - public init(usbOnly: Bool = false, delegate: ConnectionManagerDelegate? = nil) throws { - self.usbOnly = usbOnly + public init(searchMode: SearchMode = .all, delegate: ConnectionManagerDelegate? = nil) throws { + self.searchMode = searchMode self.delegate = delegate - self.token = try USBMux.subscribe { [weak self] event in + self.clientsDict = Dictionary(try USBMux.allDevices().compactMap { dev -> (ConnectionKey, ConnectionValue)? in + guard searchMode.allows(dev.connectionType) else { return nil } + let key = ConnectionKey(udid: dev.udid, connectionType: dev.connectionType) + return try (key, ConnectionValue(key: key)) + }) { _, b in b } + + if !clientsDict.isEmpty { + clientsDidChange() + } + + token = try USBMux.subscribe { [weak self] event in guard let self = self else { return } self.handleEvent(event) } @@ -105,7 +132,7 @@ public class ConnectionManager { private func handleEvent(_ event: USBMux.Event) { let connectionType = event.device.connectionType - guard !usbOnly || connectionType == .usb else { return } + guard searchMode.allows(connectionType) else { return } let udid = event.device.udid let key = ConnectionKey(udid: udid, connectionType: connectionType) switch event.kind { diff --git a/Supersign/Sources/Supersign/Integration/SuperchargeInstaller.swift b/Supersign/Sources/Supersign/Integration/SuperchargeInstaller.swift index 712234a..4f7bf45 100644 --- a/Supersign/Sources/Supersign/Integration/SuperchargeInstaller.swift +++ b/Supersign/Sources/Supersign/Integration/SuperchargeInstaller.swift @@ -15,13 +15,17 @@ extension LockdownClient { #if !os(iOS) -public protocol SuperchargeInstallerDelegate: AnyObject { +public protocol IntegratedInstallerDelegate: AnyObject { + func fetchCode(completion: @escaping (String?) -> Void) func fetchTeam(fromTeams teams: [DeveloperServicesTeam], completion: @escaping (DeveloperServicesTeam?) -> Void) - func setPresentedMessage(_ message: SuperchargeInstaller.Message?) + func setPresentedMessage(_ message: IntegratedInstaller.Message?) func installerDidUpdate(toStage stage: String, progress: Double?) func installerDidComplete(withResult result: Result<(), Error>) + // defaults to always returning true + func confirmRevocation(of certificates: [DeveloperServicesCertificate], completion: @escaping (Bool) -> Void) + /// Decompress the zipped ipa file /// /// - Parameter ipa: The `ipa` file to decompress. @@ -47,17 +51,23 @@ public protocol SuperchargeInstallerDelegate: AnyObject { /// - Parameter progress: A closure to which the callee can provide progress updates. /// - Parameter currentProgress: The current progress, or `nil` to indicate it is indeterminate. /// - Parameter completion: A completion handler to call with the compressed file's URL. - /// - Parameter url: The URL of the compressed file. The caller is responsible for cleaning this - /// up once installation is complete. Pass `nil` to skip compression. + /// - Parameter result: A result with the URL of the compressed file. The caller is responsible + /// for cleaning this up once installation is complete. func compress( payloadDir: URL, progress: @escaping (_ currentProgress: Double?) -> Void, - completion: @escaping (_ url: URL?) -> Void + completion: @escaping (_ result: Result) -> Void ) } -public final class SuperchargeInstaller { +extension IntegratedInstallerDelegate { + public func confirmRevocation(of certificates: [DeveloperServicesCertificate], completion: @escaping (Bool) -> Void) { + completion(true) + } +} + +public final class IntegratedInstaller { public enum Error: LocalizedError { case alreadyInstalling @@ -79,11 +89,18 @@ public final class SuperchargeInstaller { case unlockDevice } + public enum Credentials { + case password(String) + case token(DeveloperServicesLoginToken) + } + let udid: String + let connectionPreferences: Connection.Preferences let appleID: String - let password: String + let credentials: Credentials let signingInfoManager: SigningInfoManager - public weak var delegate: SuperchargeInstallerDelegate? + let configureDevice: Bool + public weak var delegate: IntegratedInstallerDelegate? private var appInstaller: AppInstaller? @@ -143,20 +160,24 @@ public final class SuperchargeInstaller { public init( udid: String, + connectionPreferences: Connection.Preferences, appleID: String, - password: String, + credentials: Credentials, + configureDevice: Bool, signingInfoManager: SigningInfoManager, - delegate: SuperchargeInstallerDelegate + delegate: IntegratedInstallerDelegate ) { self.udid = udid + self.connectionPreferences = connectionPreferences self.appleID = appleID - self.password = password + self.credentials = credentials + self.configureDevice = configureDevice self.signingInfoManager = signingInfoManager self.delegate = delegate } private func performWithRecovery(repeatAfter: TimeInterval = 0.1, block: () throws -> T) throws -> T { - var currMessage: SuperchargeInstaller.Message? + var currMessage: Message? defer { if currMessage != nil { @@ -166,7 +187,7 @@ public final class SuperchargeInstaller { while true { guard shouldContinue() else { throw Error.userCancelled } - let nextMessage: SuperchargeInstaller.Message + let nextMessage: Message do { return try block() } catch let error as LockdownClient.Error where error == .pairingDialogResponsePending { @@ -184,11 +205,13 @@ public final class SuperchargeInstaller { } } - private func prepareDevice() throws -> (deviceName: String, pairingKeys: Data)? { + // returns nil for pairingKeys if !configureDevice + // returns nil overall if cancelled + private func prepareDevice() throws -> (deviceName: String, pairingKeys: Data?)? { // TODO: Maybe use `Connection` here instead of creating the lockdown // client manually? - let device = try Device(udid: udid) + let device = try Device(udid: udid, lookupMode: connectionPreferences.lookupMode) guard updateProgress(to: 1/3) else { return nil } @@ -203,6 +226,11 @@ public final class SuperchargeInstaller { let deviceName = try lockdownClient.deviceName() + if !configureDevice { + guard updateProgress(to: 1) else { return nil } + return (deviceName, nil) + } + try lockdownClient.setValue(udid, forDomain: "com.apple.mobile.wireless_lockdown", key: "WirelessBuddyID") try lockdownClient.setValue(true, forDomain: "com.apple.mobile.wireless_lockdown", key: "EnableWifiConnections") @@ -263,12 +291,12 @@ public final class SuperchargeInstaller { token: DeveloperServicesLoginToken, client: DeveloperServicesClient, team: DeveloperServicesTeam, - possiblyCompressedApp: URL + ipa: URL ) { guard shouldContinue() else { return } executionStateLock.lock() defer { executionStateLock.unlock() } - let appInstaller = AppInstaller(app: possiblyCompressedApp, udid: udid, pairingKeys: ()) + let appInstaller = AppInstaller(ipa: ipa, udid: udid, connectionPreferences: connectionPreferences) self.appInstaller = appInstaller appInstaller.install( progress: { stage in @@ -294,15 +322,16 @@ public final class SuperchargeInstaller { progress: { progress in _ = self.updateProgress(to: progress, ignoreCancellation: true) }, - completion: { url in + completion: { result in guard self.updateProgress(to: 1) else { return self.completion(.failure(Error.userCancelled)) } + guard let ipa = result.get(withErrorHandler: self.completion) else { return } self.installQueue.async { self.install( deviceInfo: deviceInfo, token: token, client: client, team: team, - possiblyCompressedApp: url ?? appDir + ipa: ipa ) } } @@ -319,7 +348,7 @@ public final class SuperchargeInstaller { guard self.updateStage(to: "Preparing device") else { return } let deviceName: String - let pairingKeys: Data + let pairingKeys: Data? do { guard let prepareResult = try prepareDevice() @@ -342,7 +371,12 @@ public final class SuperchargeInstaller { } catch { return completion(.failure(error)) } - let signer = Signer(context: context) + let signer = Signer(context: context) { certs, completion in + guard let delegate = self.delegate else { + return completion(false) + } + delegate.confirmRevocation(of: certs, completion: completion) + } signer.sign( app: appDir, status: { status in @@ -352,16 +386,18 @@ public final class SuperchargeInstaller { _ = self.updateProgress(to: progress, ignoreCancellation: true) }, didProvision: { - let info = try context.signingInfoManager.info(forTeamID: context.teamID) - try Superconfig( - udid: self.udid, - pairingKeys: pairingKeys, - deviceInfo: deviceInfo, - preferredTeamID: team.id.rawValue, - preferredSigningInfo: info, - appleID: self.appleID, - token: token - ).save(inAppDir: appDir) + if let pairingKeys = pairingKeys { + let info = try context.signingInfoManager.info(forTeamID: context.teamID) + try Superconfig( + udid: self.udid, + pairingKeys: pairingKeys, + deviceInfo: deviceInfo, + preferredTeamID: team.id.rawValue, + preferredSigningInfo: info, + appleID: self.appleID, + token: token + ).save(inAppDir: appDir) + } }, completion: { result in guard result.get(withErrorHandler: self.completion) != nil else { return } @@ -408,18 +444,24 @@ public final class SuperchargeInstaller { } guard updateStage(to: "Logging in") else { return } - DeveloperServicesLoginManager(deviceInfo: deviceInfo).logIn( - withUsername: self.appleID, - password: self.password, - twoFactorDelegate: self - ) { result in - guard let token = result.get(withErrorHandler: self.completion) else { return } - guard self.updateProgress(to: 1/2) else { return } + + switch credentials { + case .password(let password): + DeveloperServicesLoginManager(deviceInfo: deviceInfo).logIn( + withUsername: self.appleID, + password: password, + twoFactorDelegate: self + ) { result in + guard let token = result.get(withErrorHandler: self.completion) else { return } + guard self.updateProgress(to: 1/2) else { return } + self.install(deviceInfo: deviceInfo, token: token, appDir: appDir) + } + case .token(let token): self.install(deviceInfo: deviceInfo, token: token, appDir: appDir) } } - private func installOnQueue(ipa: URL) { + private func installOnQueue(app: URL) { guard self.updateStage(to: "Unpacking app", initialProgress: nil) else { return } if FileManager.default.fileExists(atPath: tempDir.path) { @@ -432,30 +474,46 @@ public final class SuperchargeInstaller { return completion(.failure(error)) } - delegate?.decompress( - ipa: ipa, - in: tempDir, - progress: { progress in - _ = self.updateProgress(to: progress, ignoreCancellation: true) - }, - completion: { success in - guard self.updateProgress(to: 1) else { return self.completion(.failure(Error.userCancelled)) } - self.installQueue.async { - self.install(decompressionDidSucceed: success) + switch app.pathExtension { + case "ipa": + delegate?.decompress( + ipa: app, + in: tempDir, + progress: { progress in + _ = self.updateProgress(to: progress, ignoreCancellation: true) + }, + completion: { success in + guard self.updateProgress(to: 1) else { return self.completion(.failure(Error.userCancelled)) } + self.installQueue.async { + self.install(decompressionDidSucceed: success) + } } + ) + case "app": + let payload = tempDir.appendingPathComponent("Payload") + let dest = payload.appendingPathComponent(app.lastPathComponent) + do { + try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: false) + try FileManager.default.copyItem(at: app, to: dest) + } catch { + return completion(.failure(error)) } - ) + guard self.updateProgress(to: 1) else { return self.completion(.failure(Error.userCancelled)) } + self.install(decompressionDidSucceed: true) + default: + return completion(.failure(Error.appExtractionFailed)) + } } - public func install(ipa: URL) { + public func install(app: URL) { installQueue.async { - self.installOnQueue(ipa: ipa) + self.installOnQueue(app: app) } } } -extension SuperchargeInstaller: TwoFactorAuthDelegate { +extension IntegratedInstaller: TwoFactorAuthDelegate { public func fetchCode(completion: @escaping (String?) -> Void) { guard let delegate = delegate else { self.installQueue.async { completion(nil) } diff --git a/Supersign/Sources/Supersign/Signer/Signer.swift b/Supersign/Sources/Supersign/Signer/Signer.swift index 779a4bd..02705e9 100644 --- a/Supersign/Sources/Supersign/Signer/Signer.swift +++ b/Supersign/Sources/Supersign/Signer/Signer.swift @@ -40,8 +40,13 @@ public struct Signer { } public let context: SigningContext - public init(context: SigningContext) { + public let confirmRevocation: ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void + public init( + context: SigningContext, + confirmRevocation: @escaping ([DeveloperServicesCertificate], @escaping (Bool) -> Void) -> Void + ) { self.context = context + self.confirmRevocation = confirmRevocation } private func sign( @@ -102,7 +107,12 @@ public struct Signer { completion: @escaping (Result<(), Swift.Error>) -> Void ) { status(NSLocalizedString("signer.provisioning", value: "Provisioning", comment: "")) - DeveloperServicesProvisioningOperation(context: context, app: app, progress: progress).perform { result in + DeveloperServicesProvisioningOperation( + context: context, + app: app, + confirmRevocation: confirmRevocation, + progress: progress + ).perform { result in guard let response = result.get(withErrorHandler: completion) else { return } self.sign( app: app, diff --git a/Supersign/Sources/SupersignCLI/main.swift b/SupersignCLISupport/Sources/SupersignCLI/main.swift similarity index 76% rename from Supersign/Sources/SupersignCLI/main.swift rename to SupersignCLISupport/Sources/SupersignCLI/main.swift index fcb5ef8..229e7ea 100644 --- a/Supersign/Sources/SupersignCLI/main.swift +++ b/SupersignCLISupport/Sources/SupersignCLI/main.swift @@ -1,11 +1,12 @@ import Foundation import Supersign +import SupersignCLISupport let moduleBundle: Bundle #if swift(>=5.5) || os(macOS) moduleBundle = Bundle.module #else -moduleBundle = Bundle(url: Bundle.main.url(forResource: "Supersign_SupersignCLI", withExtension: "resources")!)! +moduleBundle = Bundle(url: Bundle.main.url(forResource: "SupersignCLISupport_SupersignCLI", withExtension: "resources")!)! #endif let app = moduleBundle.url(forResource: "Supercharge", withExtension: "ipa")! @@ -24,4 +25,7 @@ signingInfoManager = KeyValueSigningInfoManager( signingInfoManager = MemoryBackedSigningInfoManager() #endif -try SupersignCLI(app: app, signingInfoManager: signingInfoManager).run() +try SupersignCLI.run(configuration: SupersignCLI.Configuration( + superchargeApp: app, + signingInfoManager: signingInfoManager +)) diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/AuthToken.swift b/SupersignCLISupport/Sources/SupersignCLISupport/AuthToken.swift new file mode 100644 index 0000000..852d52b --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/AuthToken.swift @@ -0,0 +1,60 @@ +import Foundation +import Supersign + +struct AuthToken { + let appleID: String + let dsToken: DeveloperServicesLoginToken +} + +extension AuthToken { + + // encoded format: + // - appleID (null terminated string) + // - adsid (null terminated string) + // - expiry (double) + // - token (data) + + init?(string: String) { + guard string.hasPrefix("@") else { return nil } + guard let data = Data(base64Encoded: String(string.dropFirst())) else { return nil } + let components = data.split(separator: 0, maxSplits: 2, omittingEmptySubsequences: false) + guard components.count == 3, + let appleID = String(data: components[0], encoding: .utf8), + !appleID.isEmpty, + let adsid = String(data: components[1], encoding: .utf8), + !adsid.isEmpty + else { return nil } + + let rest = components[2] + guard rest.count > MemoryLayout.size else { return nil } + var expiry: Double = 0 + withUnsafeMutableBytes(of: &expiry) { _ = rest.copyBytes(to: $0) } + + let token = rest.advanced(by: MemoryLayout.size).base64EncodedString() + + self.appleID = appleID + self.dsToken = DeveloperServicesLoginToken( + adsid: adsid, + token: token, + expiry: Date(timeIntervalSince1970: expiry) + ) + } + + var string: String? { + guard var data = appleID.data(using: .utf8) + else { return nil } + data.append(0) + guard let adsidData = dsToken.adsid.data(using: .utf8) + else { return nil } + data.append(adsidData) + data.append(0) + withUnsafeBytes(of: dsToken.expiry.timeIntervalSince1970) { + data.append(Data($0)) + } + guard let rawToken = Data(base64Encoded: dsToken.token) + else { return nil } + data.append(rawToken) + return "@\(data.base64EncodedString())" + } + +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/ConnectionDelegate.swift b/SupersignCLISupport/Sources/SupersignCLISupport/ConnectionDelegate.swift new file mode 100644 index 0000000..3f7e777 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/ConnectionDelegate.swift @@ -0,0 +1,16 @@ +import Foundation +import Supersign + +class ConnectionDelegate: ConnectionManagerDelegate { + var onConnect: (([ConnectionManager.Client]) -> Void)? + init(onConnect: @escaping ([ConnectionManager.Client]) -> Void) { + self.onConnect = onConnect + } + + func connectionManager(_ manager: ConnectionManager, clientsDidChangeFrom oldValue: [ConnectionManager.Client]) { + let usableClients = manager.clients + guard !usableClients.isEmpty else { return } + onConnect?(usableClients) + onConnect = nil + } +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/Console.swift b/SupersignCLISupport/Sources/SupersignCLISupport/Console.swift new file mode 100644 index 0000000..1f45c34 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/Console.swift @@ -0,0 +1,97 @@ +import Foundation + +enum Console { + // nil = EOF + static func prompt(_ message: String) -> String? { + if !message.isEmpty { + print(message, terminator: "") + } + return readLine() + } + + private static func withoutEcho(_ action: () throws -> T) rethrows -> T { + #if os(Windows) + // based on https://stackoverflow.com/a/4497117/3769927 + // TODO: Confirm that this works (or even compiles) + + let hConsole = CreateFileA("CONIN$", GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0) + var dwOldMode: DWORD = 0 + GetConsoleMode(hConsole, &dwOldMode) + + let dwNewMode = dwOldMode & ~ENABLE_ECHO_INPUT + SetConsoleMode(hConsole, dwNewMode) + defer { SetConsoleMode(hConsole, dwOldMode) } + #else + var origAttr = termios() + tcgetattr(STDIN_FILENO, &origAttr) + + var newAttr = origAttr + newAttr.c_lflag = newAttr.c_lflag & ~tcflag_t(ECHO) + tcsetattr(STDIN_FILENO, TCSANOW, &newAttr) + defer { tcsetattr(STDIN_FILENO, TCSANOW, &origAttr) } + #endif + + return try action() + } + + static func getPassword(_ message: String) -> String? { + if !message.isEmpty { + print(message, terminator: "") + } + let password = withoutEcho { prompt("") } + print() + return password + } + + static func chooseNumber(in range: Range) -> Int { + while true { + print("Choice (\(range.lowerBound)-\(range.upperBound - 1)): ", terminator: "") + if let choice = readLine().flatMap(Int.init), range.contains(choice) { + return choice + } + } + } + + static func choose( + from elements: [T], + onNoElement: () throws -> T, + multiPrompt: @autoclosure () -> String, + formatter: (T) throws -> String + ) rethrows -> T { + switch elements.count { + case 0: + return try onNoElement() + case 1: + return elements[0] + default: + print(multiPrompt()) + try elements.enumerated().forEach { index, element in + try print("\(index): \(formatter(element))") + } + let choice = chooseNumber(in: elements.indices) + return elements[choice] + } + } + + private static let yesSet: Set = ["yes", "y"] + private static let noSet: Set = ["no", "n"] + + static func confirm(_ message: String) -> Bool { + while true { + if let resp = prompt("\(message) (yes/no): ")?.lowercased() { + if yesSet.contains(resp) { + return true + } else if noSet.contains(resp) { + return false + } + } + } + } + + struct Error: Swift.Error, CustomStringConvertible { + let description: String + init(_ description: String) { + self.description = description + } + } +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/DSCommand.swift b/SupersignCLISupport/Sources/SupersignCLISupport/DSCommand.swift new file mode 100644 index 0000000..1307b52 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/DSCommand.swift @@ -0,0 +1,105 @@ +import Foundation +import Supersign +import ArgumentParser + +extension DeviceInfo { + static func fetch() throws -> Self { + guard let deviceInfo = DeviceInfo.current() else { + throw Console.Error("Could not fetch client info.") + } + return deviceInfo + } +} + +private extension AuthToken { + static func retrieve(deviceInfo: DeviceInfo, username: String?, password: String?) throws -> Self { + guard let username = username ?? Console.prompt("Apple ID: "), !username.isEmpty else { + throw Console.Error("A non-empty Apple ID is required.") + } + guard let password = password ?? Console.getPassword("Password: "), !password.isEmpty else { + throw Console.Error("A non-empty password is required.") + } + let authDelegate = SupersignCLIAuthDelegate() + let token: DeveloperServicesLoginToken = try withSyncContinuation { cont in + DeveloperServicesLoginManager(deviceInfo: deviceInfo).logIn( + withUsername: username, + password: password, + twoFactorDelegate: authDelegate, + completion: cont + ) + } + _ = authDelegate + return AuthToken(appleID: username, dsToken: token) + } + + static func retrieve(deviceInfo: DeviceInfo, account: String?) throws -> Self { + if let account = account, let token = Self(string: account) { + return token + } else { + return try retrieve(deviceInfo: deviceInfo, username: account, password: nil) + } + } +} + +struct DSLoginCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "login", + abstract: "Obtain an Apple ID authentication token" + ) + + @Option(name: [.short, .long], help: "Apple ID") var username: String? + @Option(name: [.short, .long]) var password: String? + + func run() throws { + let fullToken = try AuthToken.retrieve(deviceInfo: .fetch(), username: self.username, password: self.password) + guard let string = fullToken.string else { + throw Console.Error("Could not encode token.") + } + print(string) + } +} + +struct DSTeamsListCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List Developer Services teams" + ) + + @Option(name: .shortAndLong) var account: String? + + func run() throws { + let deviceInfo = try DeviceInfo.fetch() + let token = try AuthToken.retrieve(deviceInfo: deviceInfo, account: account) + let client = DeveloperServicesClient(loginToken: token.dsToken, deviceInfo: deviceInfo) + let teams: [DeveloperServicesTeam] = try withSyncContinuation { + client.send(DeveloperServicesListTeamsRequest(), completion: $0) + } + print( + teams.map { + "\($0.name) [\($0.status)]: \($0.id.rawValue)" + + $0.memberships.map { "\n- \($0.name) (\($0.platform))" }.joined(separator: "") + }.joined(separator: "\n") + ) + } +} + +struct DSTeamsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "teams", + abstract: "Interact with Developer Services teams", + subcommands: [DSTeamsListCommand.self], + defaultSubcommand: DSTeamsListCommand.self + ) +} + +struct DSCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "ds", + abstract: "Interact with Apple Developer Services", + subcommands: [ + DSLoginCommand.self, + DSTeamsCommand.self + ] + ) +} + diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/DevicesCommand.swift b/SupersignCLISupport/Sources/SupersignCLISupport/DevicesCommand.swift new file mode 100644 index 0000000..dbb53d4 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/DevicesCommand.swift @@ -0,0 +1,32 @@ +import Foundation +import Supersign +import ArgumentParser + +extension ConnectionManager.SearchMode: EnumerableFlag { +// public static func name(for value: ConnectionManager.SearchMode) -> NameSpecification { +// [.short, .long] +// } +} + +struct DevicesCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "devices", + abstract: "List devices" + ) + + @Flag(help: "Which devices to search for") var search: ConnectionManager.SearchMode = .all + + func run() throws { + var _clients: [ConnectionManager.Client]! + let semaphore = DispatchSemaphore(value: 0) + let connDelegate = ConnectionDelegate { currClients in + _clients = currClients + semaphore.signal() + } + try withExtendedLifetime(ConnectionManager(searchMode: search, delegate: connDelegate)) { + semaphore.wait() + } + let clients = _clients! + print(clients.map { "\($0.deviceName) [\($0.connectionType)]: \($0.udid)" }.joined(separator: "\n")) + } +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/InstallCommand.swift b/SupersignCLISupport/Sources/SupersignCLISupport/InstallCommand.swift new file mode 100644 index 0000000..12538a4 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/InstallCommand.swift @@ -0,0 +1,79 @@ +import Foundation +import Supersign +import SwiftyMobileDevice +import ArgumentParser + +struct InstallCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install", + abstract: "Install an ipa file to your device" + ) + + @Option(name: .shortAndLong) var udid: String? + @Option(name: .shortAndLong) var account: String? + @Flag var search: ConnectionManager.SearchMode = .all + + @Argument( + help: "The path to a custom app/ipa to install" + ) var path: String + + func run() throws { + let username: String + let credentials: IntegratedInstaller.Credentials + + if let account = account, let auth = AuthToken(string: account) { + username = auth.appleID + credentials = .token(auth.dsToken) + } else { + guard let appleID = account ?? Console.prompt("Apple ID: "), + let password = Console.getPassword("Password: ") + else { return } + username = appleID + credentials = .password(password) + } + + print("Waiting for device to be connected...") + var clients: [ConnectionManager.Client]! + let semaphore = DispatchSemaphore(value: 0) + let connDelegate = ConnectionDelegate { currClients in + if let udid = udid { + if let client = currClients.first(where: { $0.udid == udid }) { + clients = [client] + } else { + return + } + } else { + clients = currClients + } + semaphore.signal() + } + try withExtendedLifetime(ConnectionManager(searchMode: search, delegate: connDelegate)) { + semaphore.wait() + } + + let client = Console.choose( + from: clients, + onNoElement: { fatalError() }, + multiPrompt: "Choose device", + formatter: { "\($0.deviceName) (\($0.connectionType), udid: \($0.udid))" } + ) + + print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + + let installDelegate = SupersignCLIDelegate(preferredTeam: nil) { + semaphore.signal() + } + let installer = IntegratedInstaller( + udid: client.udid, + connectionPreferences: .init(lookupMode: .only(client.connectionType)), + appleID: username, + credentials: credentials, + configureDevice: false, + signingInfoManager: SupersignCLI.config.signingInfoManager, + delegate: installDelegate + ) + installer.install(app: URL(fileURLWithPath: path)) + semaphore.wait() + _ = installer + } +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/SuperchargeCommand.swift b/SupersignCLISupport/Sources/SupersignCLISupport/SuperchargeCommand.swift new file mode 100644 index 0000000..2dec395 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/SuperchargeCommand.swift @@ -0,0 +1,82 @@ +import Foundation +import Supersign +import ArgumentParser + +struct InstallSuperchargeCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install", + abstract: "Install Supercharge" + ) + + @Option(name: .shortAndLong) var udid: String? + @Option(name: .shortAndLong) var account: String? + + func run() throws { + let username: String + let credentials: IntegratedInstaller.Credentials + + if let account = account, let auth = AuthToken(string: account) { + username = auth.appleID + credentials = .token(auth.dsToken) + } else { + guard let appleID = account ?? Console.prompt("Apple ID: "), + let password = Console.getPassword("Password: ") + else { return } + username = appleID + credentials = .password(password) + } + + print("Waiting for device to be connected...") + var clients: [ConnectionManager.Client]! + let semaphore = DispatchSemaphore(value: 0) + let connDelegate = ConnectionDelegate { currClients in + if let udid = udid { + if let client = currClients.first(where: { $0.udid == udid }) { + clients = [client] + } else { + return + } + } else { + clients = currClients + } + semaphore.signal() + } + try withExtendedLifetime(ConnectionManager(searchMode: .usb, delegate: connDelegate)) { + semaphore.wait() + } + + let client = Console.choose( + from: clients, + onNoElement: { fatalError() }, + multiPrompt: "Choose device", + formatter: { "\($0.deviceName) (udid: \($0.udid))" } + ) + + print("Installing to device: \(client.deviceName) (udid: \(client.udid))") + + let installDelegate = SupersignCLIDelegate(preferredTeam: nil) { + semaphore.signal() + } + let installer = IntegratedInstaller( + udid: client.udid, + connectionPreferences: .init(lookupMode: .only(.usb)), + appleID: username, + credentials: credentials, + configureDevice: true, + signingInfoManager: SupersignCLI.config.signingInfoManager, + delegate: installDelegate + ) + installer.install(app: SupersignCLI.config.superchargeApp) + semaphore.wait() + _ = installer + } +} + +struct SuperchargeCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "supercharge", + abstract: "Configure/install Supercharge", + subcommands: [InstallSuperchargeCommand.self], + defaultSubcommand: InstallSuperchargeCommand.self + ) +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCLIDelegate.swift b/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCLIDelegate.swift new file mode 100644 index 0000000..a7e218b --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCLIDelegate.swift @@ -0,0 +1,166 @@ +import Foundation +import Supersign + +private func _fetchCode(completion: @escaping (String?) -> Void) { + completion(Console.prompt("Code: ")) +} + +final class SupersignCLIAuthDelegate: TwoFactorAuthDelegate { + func fetchCode(completion: @escaping (String?) -> Void) { + _fetchCode(completion: completion) + } +} + +final class SupersignCLIDelegate: IntegratedInstallerDelegate, TwoFactorAuthDelegate { + public enum Error: Swift.Error { + case compressionFailed + } + + let preferredTeam: DeveloperServicesTeam.ID? + let completion: () -> Void + init(preferredTeam: DeveloperServicesTeam.ID?, completion: @escaping () -> Void) { + self.preferredTeam = preferredTeam + self.completion = completion + } + + func fetchCode(completion: @escaping (String?) -> Void) { + _fetchCode(completion: completion) + } + + func fetchTeam(fromTeams teams: [DeveloperServicesTeam], completion: @escaping (DeveloperServicesTeam?) -> Void) { + let selected = Console.choose( + from: teams, + onNoElement: { fatalError() }, + multiPrompt: "Select a team", + formatter: { + "\($0.name) (\($0.id))" + } + ) + completion(selected) + } + + func setPresentedMessage(_ message: IntegratedInstaller.Message?) { + let text: String + switch message { + case .pairDevice: + text = "Please tap 'trust' on your device..." + case .unlockDevice: + text = "Please unlock your device..." + case nil: + text = "Continuing..." + } + print("\n\(text)", terminator: "") + fflush(stdout) + } + + var prevStage: String? + var prevProgress: String? + + func installerDidUpdate(toStage stage: String, progress: Double?) { + let progString: String? + if let progress = progress { + let progInt = Int(progress * 100) + if progInt < 10 { + progString = " \(progInt)%" + } else if progInt < 100 { + progString = " \(progInt)%" + } else { + progString = "\(progInt)%" + } + } else { + progString = nil + } + + defer { + prevStage = stage + prevProgress = progString + } + + if stage != prevStage { + if let progString = progString { + print("\n[\(stage)] \(progString)", terminator: "") + fflush(stdout) + } else { + print("\n[\(stage)] ...", terminator: "") + fflush(stdout) + } + } else if progString != prevProgress { + if let progString = progString { + print("\r[\(stage)] \(progString)", terminator: "") + fflush(stdout) + } else { + print("\r[\(stage)]", terminator: "") + fflush(stdout) + } + } + } + + func installerDidComplete(withResult result: Result<(), Swift.Error>) { + print("\n") + switch result { + case .success: + print("Complete!") + case .failure(let error): + print("Failed :(") + print("Error: \(error)") + } + completion() + } + + private static let expiryFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + func confirmRevocation(of certificates: [DeveloperServicesCertificate], completion: @escaping (Bool) -> Void) { + print("\nThe following certificates must be revoked:") + print( + certificates.map { + "- \($0.attributes.name) (expires \(Self.expiryFormatter.string(from: $0.attributes.expiry)))" + }.joined(separator: "\n") + ) + completion(Console.confirm("Continue?")) + } + + // TODO: Use `powershell Compress-Archive` and `powershell Expand-Archive` on Windows + + func decompress( + ipa: URL, + in directory: URL, + progress: @escaping (Double?) -> Void, + completion: @escaping (_ success: Bool) -> Void + ) { + progress(nil) + + let unzip = Process() + unzip.launchPath = "/usr/bin/unzip" + unzip.arguments = ["-q", ipa.path, "-d", directory.path] + unzip.launch() + unzip.waitUntilExit() + guard unzip.terminationStatus == 0 else { return completion(false) } + + completion(true) + } + + func compress( + payloadDir: URL, + progress: @escaping (Double?) -> Void, + completion: @escaping (Result) -> Void + ) { + progress(nil) + + let dest = payloadDir.deletingLastPathComponent().appendingPathComponent("app.ipa") + + let zip = Process() + zip.launchPath = "/usr/bin/zip" + zip.currentDirectoryPath = payloadDir.deletingLastPathComponent().path + zip.arguments = ["-yqru0", dest.path, "Payload"] + zip.launch() + zip.waitUntilExit() + guard zip.terminationStatus == 0 else { return completion(.failure(Error.compressionFailed)) } + + completion(.success(dest)) + } +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCommand.swift b/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCommand.swift new file mode 100644 index 0000000..2d40c39 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/SupersignCommand.swift @@ -0,0 +1,37 @@ +import Foundation +import Supersign +import ArgumentParser + +public enum SupersignCLI { + public struct Configuration { + public let superchargeApp: URL + public let signingInfoManager: SigningInfoManager + + public init(superchargeApp: URL, signingInfoManager: SigningInfoManager) { + self.superchargeApp = superchargeApp + self.signingInfoManager = signingInfoManager + } + } + + private static var _config: Configuration! + static var config: Configuration { _config } + + public static func run(configuration: Configuration, arguments: [String]? = nil) throws { + _config = configuration + defer { defaultHTTPClientFactory.shutdown() } + SupersignCommand.main(arguments) + } +} + +struct SupersignCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "supersign", + abstract: "The Supersign command line tool", + subcommands: [ + DSCommand.self, + DevicesCommand.self, + InstallCommand.self, + SuperchargeCommand.self, + ] + ) +} diff --git a/SupersignCLISupport/Sources/SupersignCLISupport/Utilities.swift b/SupersignCLISupport/Sources/SupersignCLISupport/Utilities.swift new file mode 100644 index 0000000..efb0210 --- /dev/null +++ b/SupersignCLISupport/Sources/SupersignCLISupport/Utilities.swift @@ -0,0 +1,21 @@ +import Foundation +import Supersign + +func withSyncContinuation( + _ action: (_ cont: @escaping (T) -> Void) -> Void +) -> T { + var value: T! + let sem = DispatchSemaphore(value: 0) + action { + value = $0 + sem.signal() + } + sem.wait() + return value +} + +func withSyncContinuation( + _ action: (_ cont: @escaping (Result) -> Void) -> Void +) throws -> T { + try withSyncContinuation(action).get() +}