Add an actual Supersign CLI, fix supersign bugs

This commit is contained in:
kabiroberai
2021-08-06 21:19:57 +05:30
committed by Kabir Oberai
Unverified
parent efed4185e2
commit f3c5da60bf
21 changed files with 968 additions and 143 deletions
-17
View File
@@ -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
)
]
)
@@ -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<SigningInfo, Swift.Error>) -> Void
) {
let grouper = RequestGrouper<EmptyResponse, Swift.Error>()
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<EmptyResponse, Swift.Error>()
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)
}
}
}
@@ -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)
}
}
@@ -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
@@ -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)
@@ -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)
})
}
}
@@ -13,8 +13,6 @@ import SwiftyMobileDevice
import USBMuxSim
import PortForwarding
public typealias PairingKeys = Data
private extension USBMuxSimulator {
func performAtomically<T>(_ 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<T: AnyObject> {
@@ -39,25 +35,39 @@ private class WeakBox<T: AnyObject> {
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<Connection>] = [:]
private static var connections: [ConnectionDescriptor: WeakBox<Connection>] = [:]
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
}
}
@@ -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 {
@@ -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<URL, Error>) -> 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<T>(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) }
@@ -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,
@@ -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
))
@@ -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<Double>.size else { return nil }
var expiry: Double = 0
withUnsafeMutableBytes(of: &expiry) { _ = rest.copyBytes(to: $0) }
let token = rest.advanced(by: MemoryLayout<Double>.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())"
}
}
@@ -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
}
}
@@ -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<T>(_ 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>) -> 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<T>(
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<String> = ["yes", "y"]
private static let noSet: Set<String> = ["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
}
}
}
@@ -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
]
)
}
@@ -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"))
}
}
@@ -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
}
}
@@ -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
)
}
@@ -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<URL, Swift.Error>) -> 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))
}
}
@@ -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,
]
)
}
@@ -0,0 +1,21 @@
import Foundation
import Supersign
func withSyncContinuation<T>(
_ 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<T>(
_ action: (_ cont: @escaping (Result<T, Error>) -> Void) -> Void
) throws -> T {
try withSyncContinuation(action).get()
}