Installer refactoring and bug fixes

- Create new Mac CLI with better keychain support
- Fix auto-created member center device name
- Misc refactoring
This commit is contained in:
kabiroberai
2021-07-14 05:50:06 +05:30
committed by Kabir Oberai
parent bf2729321e
commit e05edc63bd
13 changed files with 82 additions and 309 deletions

View File

@@ -45,7 +45,7 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera
platform: context.platform,
teamID: context.teamID,
csr: csr,
machineName: "Supercharge: \(context.deviceName)",
machineName: "Supercharge: \(SigningContext.hostName)",
machineID: context.client.deviceInfo.deviceID
)
context.client.send(request) { result in

View File

@@ -61,14 +61,14 @@ public final class DeveloperServicesClient {
public init(
loginToken: DeveloperServicesLoginToken,
deviceInfo: DeviceInfo,
httpFactory: HTTPClientFactory.Type = defaultHTTPClientFactory,
httpFactory: HTTPClientFactory = defaultHTTPClientFactory,
customAnisetteDataProvider: AnisetteDataProvider? = nil
) {
self.loginToken = loginToken
self.deviceInfo = deviceInfo
self.anisetteDataProvider = customAnisetteDataProvider
?? SupersetteDataProvider(deviceInfo: deviceInfo)
self.httpClient = httpFactory.shared.makeClient()
self.httpClient = httpFactory.makeClient()
}
private func send<R: DeveloperServicesRequest>(

View File

@@ -23,9 +23,9 @@ public class SupersetteDataProvider: AnisetteDataProvider {
public let deviceInfo: DeviceInfo
private let httpClient: HTTPClientProtocol
public init(deviceInfo: DeviceInfo, httpFactory: HTTPClientFactory.Type = defaultHTTPClientFactory) {
public init(deviceInfo: DeviceInfo, httpFactory: HTTPClientFactory = defaultHTTPClientFactory) {
self.deviceInfo = deviceInfo
self.httpClient = httpFactory.shared.makeClient()
self.httpClient = httpFactory.makeClient()
}
private struct AnisetteRequestBody: Encodable {

View File

@@ -19,14 +19,14 @@ class GrandSlamClient {
private let httpClient: HTTPClientProtocol
init(
deviceInfo: DeviceInfo,
httpFactory: HTTPClientFactory.Type = defaultHTTPClientFactory,
httpFactory: HTTPClientFactory = defaultHTTPClientFactory,
customAnisetteDataProvider: AnisetteDataProvider? = nil
) {
self.deviceInfo = deviceInfo
self.anisetteDataProvider = customAnisetteDataProvider
?? SupersetteDataProvider(deviceInfo: deviceInfo)
self.lookupManager = .init(deviceInfo: deviceInfo, httpFactory: httpFactory)
self.httpClient = httpFactory.shared.makeClient()
self.httpClient = httpFactory.makeClient()
}
private func send<R: GrandSlamRequest>(

View File

@@ -21,9 +21,9 @@ class GrandSlamLookupManager {
let httpClient: HTTPClientProtocol
let deviceInfo: DeviceInfo
init(deviceInfo: DeviceInfo, httpFactory: HTTPClientFactory.Type = defaultHTTPClientFactory) {
init(deviceInfo: DeviceInfo, httpFactory: HTTPClientFactory = defaultHTTPClientFactory) {
self.deviceInfo = deviceInfo
self.httpClient = httpFactory.shared.makeClient()
self.httpClient = httpFactory.makeClient()
}
private func performLookup(completion: @escaping (Result<GrandSlamEndpoints, Error>) -> Void) {

View File

@@ -89,7 +89,7 @@ extension HTTPClient: HTTPClientProtocol {
}
}
public final class AsyncHTTPClientFactory: HTTPClientFactory {
final class AsyncHTTPClientFactory: HTTPClientFactory {
private static let appleRootPEM = """
-----BEGIN CERTIFICATE-----
MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET
@@ -131,12 +131,12 @@ public final class AsyncHTTPClientFactory: HTTPClientFactory {
)
client = HTTPClient(eventLoopGroupProvider: .createNew, configuration: config)
}
public static let shared = AsyncHTTPClientFactory()
static let shared = AsyncHTTPClientFactory()
public func shutdown() {
func shutdown() {
try! client.syncShutdown()
}
public func makeClient() -> HTTPClientProtocol { client }
func makeClient() -> HTTPClientProtocol { client }
}
#endif

View File

@@ -66,15 +66,14 @@ public protocol HTTPClientProtocol {
}
public protocol HTTPClientFactory {
static var shared: Self { get }
func shutdown() // client is invalidated after this
func makeClient() -> HTTPClientProtocol
}
public let defaultHTTPClientFactory: HTTPClientFactory.Type = {
public let defaultHTTPClientFactory: HTTPClientFactory = {
#if os(Linux)
return AsyncHTTPClientFactory.self
return AsyncHTTPClientFactory.shared
#else
return URLHTTPClientFactory.self
return URLHTTPClientFactory.shared
#endif
}()

View File

@@ -57,8 +57,8 @@ extension URLSession: HTTPClientProtocol {
}
}
public final class URLHTTPClientFactory: HTTPClientFactory {
public static let shared = URLHTTPClientFactory()
final class URLHTTPClientFactory: HTTPClientFactory {
static let shared = URLHTTPClientFactory()
// no need for cert handling since the Apple Root CA is already
// installed on any devices which support the Security APIs which
@@ -66,7 +66,7 @@ public final class URLHTTPClientFactory: HTTPClientFactory {
private let session = URLSession(configuration: .ephemeral)
private init() {}
public func shutdown() {}
public func makeClient() -> HTTPClientProtocol { session }
func shutdown() {}
func makeClient() -> HTTPClientProtocol { session }
}
#endif

View File

@@ -82,6 +82,7 @@ public final class SuperchargeInstaller {
let udid: String
let appleID: String
let password: String
let signingInfoManager: SigningInfoManager
public weak var delegate: SuperchargeInstallerDelegate?
private var appInstaller: AppInstaller?
@@ -144,45 +145,46 @@ public final class SuperchargeInstaller {
udid: String,
appleID: String,
password: String,
signingInfoManager: SigningInfoManager,
delegate: SuperchargeInstallerDelegate
) {
self.udid = udid
self.appleID = appleID
self.password = password
self.signingInfoManager = signingInfoManager
self.delegate = delegate
}
private func performWithRecovery<T>(block: () throws -> T) throws -> T {
var presentedMessage: SuperchargeInstaller.Message?
func present(message: SuperchargeInstaller.Message) throws {
if presentedMessage != message {
delegate?.setPresentedMessage(message)
}
presentedMessage = message
// allow some time before looping
Thread.sleep(forTimeInterval: 0.1)
}
private func performWithRecovery<T>(repeatAfter: TimeInterval = 0.1, block: () throws -> T) throws -> T {
var currMessage: SuperchargeInstaller.Message?
defer {
if presentedMessage != nil {
if currMessage != nil {
delegate?.setPresentedMessage(nil)
}
}
while true {
guard shouldContinue() else { throw Error.userCancelled }
let nextMessage: SuperchargeInstaller.Message
do {
return try block()
} catch let error as LockdownClient.Error where error == .pairingDialogResponsePending {
try present(message: .pairDevice)
nextMessage = .pairDevice
} catch let error as LockdownClient.Error where error == .passwordProtected {
try present(message: .unlockDevice)
nextMessage = .unlockDevice
}
if currMessage != nextMessage {
delegate?.setPresentedMessage(nextMessage)
currMessage = nextMessage
}
if repeatAfter > 0 {
Thread.sleep(forTimeInterval: repeatAfter)
}
}
}
private func fetchPairingKeys() throws -> Data? {
private func prepareDevice() throws -> (deviceName: String, pairingKeys: Data)? {
// TODO: Maybe use `Connection` here instead of creating the lockdown
// client manually?
@@ -199,6 +201,8 @@ public final class SuperchargeInstaller {
)
}
let deviceName = try lockdownClient.deviceName()
try lockdownClient.setValue(udid, forDomain: "com.apple.mobile.wireless_lockdown", key: "WirelessBuddyID")
try lockdownClient.setValue(true, forDomain: "com.apple.mobile.wireless_lockdown", key: "EnableWifiConnections")
@@ -251,7 +255,7 @@ public final class SuperchargeInstaller {
guard updateProgress(to: 1) else { return nil }
return data
return (deviceName, data)
}
private func install(
@@ -314,30 +318,22 @@ public final class SuperchargeInstaller {
) {
guard self.updateStage(to: "Preparing device") else { return }
let deviceName: String
let pairingKeys: Data
do {
guard let _pairingKeys = try fetchPairingKeys()
guard let prepareResult = try prepareDevice()
else { return } // nil: user cancelled
pairingKeys = _pairingKeys
(deviceName, pairingKeys) = prepareResult
} catch {
return completion(.failure(error))
}
#warning("Persist manager on Windows/Linux")
let signingInfoManager: SigningInfoManager
#if os(macOS)
signingInfoManager = KeyValueSigningInfoManager(
storage: KeychainStorage(service: "com.kabiroberai.Supercharge-Installer.credentials")
)
#else
signingInfoManager = MemoryBackedSigningInfoManager()
#endif
let context: SigningContext
do {
context = try SigningContext(
udid: udid,
deviceName: deviceName,
teamID: team.id,
client: client,
signingInfoManager: signingInfoManager,
@@ -461,7 +457,11 @@ public final class SuperchargeInstaller {
extension SuperchargeInstaller: TwoFactorAuthDelegate {
public func fetchCode(completion: @escaping (String?) -> Void) {
delegate?.fetchCode { code in
guard let delegate = delegate else {
self.installQueue.async { completion(nil) }
return
}
delegate.fetchCode { code in
self.installQueue.async {
completion(code)
}

View File

@@ -11,6 +11,7 @@ import Foundation
public struct SigningContext {
public let udid: String
public let deviceName: String
public let teamID: DeveloperServicesTeam.ID
public let client: DeveloperServicesClient
public let signingInfoManager: SigningInfoManager
@@ -19,6 +20,7 @@ public struct SigningContext {
public init(
udid: String,
deviceName: String,
teamID: DeveloperServicesTeam.ID,
client: DeveloperServicesClient,
signingInfoManager: SigningInfoManager,
@@ -26,6 +28,7 @@ public struct SigningContext {
signerImpl: SignerImpl? = nil
) throws {
self.udid = udid
self.deviceName = deviceName
self.teamID = teamID
self.client = client
self.signingInfoManager = signingInfoManager
@@ -39,7 +42,7 @@ public struct SigningContext {
import UIKit
#endif
extension SigningContext {
public var deviceName: String {
public static var hostName: String {
#if targetEnvironment(simulator)
return "Simulator"
#elseif canImport(UIKit)

View File

@@ -73,6 +73,9 @@ public struct KeychainStorage: KeyValueStorage {
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key
]
if #available(macOS 10.15, *) {
query[kSecUseDataProtectionKeychain] = true
}
query[kSecAttrService] = service
query.merge(parameters) { _, b in b }
return query as CFDictionary

View File

@@ -44,14 +44,8 @@ public class MemoryBackedSigningInfoManager: SigningInfoManager {
}
public struct KeyValueSigningInfoManager: SigningInfoManager {
private enum KeyType: String {
case certificate
case privateKey
}
private func key(_ teamID: DeveloperServicesTeam.ID, _ keyType: KeyType) -> String {
"\(teamID.rawValue).\(keyType.rawValue)"
}
private let encoder = PropertyListEncoder()
private let decoder = PropertyListDecoder()
public let storage: KeyValueStorage
public init(storage: KeyValueStorage) {
@@ -59,22 +53,13 @@ public struct KeyValueSigningInfoManager: SigningInfoManager {
}
public func info(forTeamID teamID: DeveloperServicesTeam.ID) throws -> SigningInfo? {
guard let privKeyData = try storage.data(forKey: key(teamID, .privateKey)),
let certData = try storage.data(forKey: key(teamID, .certificate))
guard let data = try storage.data(forKey: teamID.rawValue)
else { return nil }
let cert = try Certificate(data: certData)
let privKey = PrivateKey(data: privKeyData)
return SigningInfo(privateKey: privKey, certificate: cert)
return try decoder.decode(SigningInfo.self, from: data)
}
public func setInfo(_ info: SigningInfo?, forTeamID teamID: DeveloperServicesTeam.ID) throws {
try storage.setData(
info?.certificate.data(),
forKey: key(teamID, .certificate)
)
try storage.setData(
info?.privateKey.data,
forKey: key(teamID, .privateKey)
)
let data = try encoder.encode(info)
try storage.setData(data, forKey: teamID.rawValue)
}
}

View File

@@ -1,244 +1,27 @@
import Foundation
import Supersign
defer { defaultHTTPClientFactory.shared.shutdown() }
let moduleBundle: Bundle
#if swift(>=5.5) || os(macOS)
moduleBundle = Bundle.module
#else
moduleBundle = Bundle(url: Bundle.main.url(forResource: "Supersign_SupersignCLI", withExtension: "resources")!)!
#endif
let app = moduleBundle.url(forResource: "Supercharge", withExtension: "ipa")!
// nil = EOF
func prompt(_ message: String) -> String? {
if !message.isEmpty {
print(message, terminator: "")
}
return readLine()
}
#warning("Persist manager on Windows/Linux")
func getPassword(_ message: String) -> String? {
if !message.isEmpty {
print(message, terminator: "")
}
// for Windows, we could use dpapi.h or wincred.h.
// for Linux, maybe use libsecret?
// see https://github.com/atom/node-keytar
var origAttr = termios()
tcgetattr(STDIN_FILENO, &origAttr)
let signingInfoManager: SigningInfoManager
#if os(macOS)
signingInfoManager = KeyValueSigningInfoManager(
storage: KeychainStorage(service: "com.kabiroberai.Supercharge-Keychain.credentials")
)
#else
signingInfoManager = MemoryBackedSigningInfoManager()
#endif
var newAttr = origAttr
newAttr.c_lflag = newAttr.c_lflag & ~tcflag_t(ECHO)
tcsetattr(STDIN_FILENO, TCSANOW, &newAttr)
let password = prompt("")
tcsetattr(STDIN_FILENO, TCSANOW, &origAttr)
print()
return password
}
func chooseNumber(in range: Range<Int>) -> Int {
print("Choice (\(range.lowerBound)-\(range.upperBound - 1)): ", terminator: "")
guard let choice = readLine().flatMap(Int.init), range.contains(choice)
else { return chooseNumber(in: range) }
return choice
}
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]
}
}
class SupersignCLIDelegate: SuperchargeInstallerDelegate {
let completion: () -> Void
init(completion: @escaping () -> Void) {
self.completion = completion
}
func fetchCode(completion: @escaping (String?) -> Void) {
let code = prompt("Code: ")
completion(code)
}
func fetchTeam(fromTeams teams: [DeveloperServicesTeam], completion: @escaping (DeveloperServicesTeam?) -> Void) {
let selected = choose(
from: teams,
onNoElement: { fatalError() },
multiPrompt: "Select a team",
formatter: {
"\($0.name) (\($0.id))"
}
)
completion(selected)
}
func setPresentedMessage(_ message: SuperchargeInstaller.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<(), Error>) {
print("\n")
switch result {
case .success:
print("Complete!")
case .failure(let error):
print("Failed :(")
print("Error: \(error)")
}
completion()
}
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 (URL?) -> 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(nil) }
completion(dest)
}
}
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
}
}
func main() throws {
let moduleBundle: Bundle
#if swift(>=5.5) || os(macOS)
moduleBundle = Bundle.module
#else
moduleBundle = Bundle(url: Bundle.main.url(forResource: "Supersign_SupersignCLI", withExtension: "resources")!)!
#endif
let app = moduleBundle.url(forResource: "Supercharge", withExtension: "ipa")!
guard let username = prompt("Apple ID: "),
let password = getPassword("Password: ")
else { return }
print("Waiting for device to be connected...")
var clients: [ConnectionManager.Client]!
let semaphore = DispatchSemaphore(value: 0)
let connDelegate = ConnectionDelegate { currClients in
clients = currClients
semaphore.signal()
}
try withExtendedLifetime(ConnectionManager(usbOnly: true, delegate: connDelegate)) {
semaphore.wait()
}
let client = 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 {
semaphore.signal()
}
let installer = SuperchargeInstaller(udid: client.udid, appleID: username, password: password, delegate: installDelegate)
installer.install(ipa: app)
semaphore.wait()
_ = installer
}
try main()
try SupersignCLI(app: app, signingInfoManager: signingInfoManager).run()