mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-15 20:15:01 +03:00
Add an actual Supersign CLI, fix supersign bugs
This commit is contained in:
committed by
Kabir Oberai
Unverified
parent
efed4185e2
commit
f3c5da60bf
@@ -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
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
+22
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -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,
|
||||
|
||||
+6
-2
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user