mirror of
https://github.com/xtool-org/xtool.git
synced 2026-02-04 11:53:30 +01:00
- We now have a single tmpdir "root" that can be recreated at launch to clean up old stragglers - The location of this tmpdir root can be controlled by `XTL_TMPDIR` or `TMPDIR`. In general the path is `$TMPDIR/sh.xtool`. With this change it should be possible to `export XTL_TMPDIR=/var/tmp` if `/tmp` doesn't have enough space, which fixes #23.
320 lines
10 KiB
Swift
320 lines
10 KiB
Swift
import Foundation
|
|
import SwiftyMobileDevice
|
|
import ConcurrencyExtras
|
|
import Dependencies
|
|
import XUtils
|
|
|
|
extension LockdownClient {
|
|
static let installerLabel = "xtool"
|
|
}
|
|
|
|
#if !os(iOS)
|
|
|
|
public protocol IntegratedInstallerDelegate: AnyObject, Sendable {
|
|
func setPresentedMessage(_ message: IntegratedInstaller.Message?)
|
|
func installerDidUpdate(toStage stage: String, progress: Double?)
|
|
|
|
// defaults to always returning true
|
|
func confirmRevocation(of certificates: [DeveloperServicesCertificate]) async -> Bool
|
|
}
|
|
|
|
extension IntegratedInstallerDelegate {
|
|
public func confirmRevocation(of certificates: [DeveloperServicesCertificate]) async -> Bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
public actor IntegratedInstaller {
|
|
|
|
public enum Error: LocalizedError {
|
|
case alreadyInstalling
|
|
case deviceInfoFetchFailed
|
|
case noTeamFound
|
|
case appExtractionFailed
|
|
case appCorrupted
|
|
case appPackagingFailed
|
|
case pairingFailed
|
|
|
|
public var errorDescription: String? {
|
|
"Installer.Error.\(self)"
|
|
}
|
|
}
|
|
|
|
public enum Message {
|
|
case pairDevice
|
|
case unlockDevice
|
|
}
|
|
|
|
let udid: String
|
|
let lookupMode: LookupMode
|
|
let auth: DeveloperAPIAuthData
|
|
let configureDevice: Bool
|
|
public weak var delegate: IntegratedInstallerDelegate?
|
|
|
|
@Dependency(\.zipCompressor) private var compressor
|
|
|
|
private var appInstaller: AppInstaller?
|
|
|
|
private var stage: String?
|
|
|
|
private nonisolated let updateTask = LockIsolated<Task<Void, Never>?>(nil)
|
|
|
|
private nonisolated func queueUpdateTask(
|
|
_ perform: @escaping @Sendable (isolated IntegratedInstaller) async -> Void
|
|
) {
|
|
updateTask.withValue { task in
|
|
task = Task { [prev = task] in
|
|
await prev?.value
|
|
await perform(self)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateStage(
|
|
to stage: String,
|
|
initialProgress: Double? = 0
|
|
) async throws {
|
|
await Task.yield()
|
|
try Task.checkCancellation()
|
|
updateStageIgnoringCancellation(to: stage, initialProgress: initialProgress)
|
|
}
|
|
|
|
private func updateStageIgnoringCancellation(
|
|
to stage: String,
|
|
initialProgress: Double? = 0
|
|
) {
|
|
guard self.stage != stage else { return }
|
|
self.stage = stage
|
|
delegate?.installerDidUpdate(toStage: stage, progress: initialProgress)
|
|
}
|
|
|
|
private func updateProgress(to progress: Double?) async throws {
|
|
await Task.yield()
|
|
try Task.checkCancellation()
|
|
updateProgressIgnoringCancellation(to: progress)
|
|
}
|
|
|
|
private func updateProgressIgnoringCancellation(to progress: Double?) {
|
|
guard let stage = stage else {
|
|
preconditionFailure("Cannot change progress without setting stage at least once")
|
|
}
|
|
delegate?.installerDidUpdate(toStage: stage, progress: progress)
|
|
}
|
|
|
|
public init(
|
|
udid: String,
|
|
lookupMode: LookupMode,
|
|
auth: DeveloperAPIAuthData,
|
|
configureDevice: Bool,
|
|
delegate: IntegratedInstallerDelegate
|
|
) {
|
|
self.udid = udid
|
|
self.lookupMode = lookupMode
|
|
self.auth = auth
|
|
self.configureDevice = configureDevice
|
|
self.delegate = delegate
|
|
}
|
|
|
|
private func performWithRecovery<T>(
|
|
repeatAfter: TimeInterval = 0.1,
|
|
block: () async throws -> sending T
|
|
) async throws -> sending T {
|
|
var currMessage: Message?
|
|
|
|
defer {
|
|
if currMessage != nil {
|
|
delegate?.setPresentedMessage(nil)
|
|
}
|
|
}
|
|
|
|
while true {
|
|
let nextMessage: Message
|
|
do {
|
|
return try await block()
|
|
} catch let error as LockdownClient.Error where error == .pairingDialogResponsePending {
|
|
nextMessage = .pairDevice
|
|
} catch let error as LockdownClient.Error where error == .passwordProtected {
|
|
nextMessage = .unlockDevice
|
|
}
|
|
if currMessage != nextMessage {
|
|
delegate?.setPresentedMessage(nextMessage)
|
|
currMessage = nextMessage
|
|
}
|
|
try await Task.sleep(seconds: repeatAfter)
|
|
}
|
|
}
|
|
|
|
private func fetchPairingKeys(with lockdownClient: LockdownClient) async throws -> Data {
|
|
try lockdownClient.setValue(udid, forDomain: "com.apple.mobile.wireless_lockdown", key: "WirelessBuddyID")
|
|
try lockdownClient.setValue(true, forDomain: "com.apple.mobile.wireless_lockdown", key: "EnableWifiConnections")
|
|
|
|
try await updateProgress(to: 2/3)
|
|
|
|
// now create a new pair record based off the existing one, but replacing the
|
|
// SystemBUID and HostID. This is necessary because if two machines with the
|
|
// same HostID try accessing lockdown with the same device wirelessly, it'll
|
|
// fail during the heartbeat. The SystemBUID also has to be different because
|
|
// we can only have one HostID per SystemBUID on iOS 15+
|
|
let oldRecord = try USBMux.pairRecord(forUDID: udid)
|
|
|
|
var plistFormat: PropertyListSerialization.PropertyListFormat = .xml
|
|
guard var plist = try PropertyListSerialization
|
|
.propertyList(from: oldRecord, options: [], format: &plistFormat)
|
|
as? [String: Any],
|
|
let deviceCert = plist["DeviceCertificate"] as? Data,
|
|
let hostCert = plist["HostCertificate"] as? Data,
|
|
let rootCert = plist["RootCertificate"] as? Data,
|
|
let systemBUIDString = plist["SystemBUID"] as? String,
|
|
let systemBUID = UUID(uuidString: systemBUIDString)
|
|
else { throw Error.pairingFailed }
|
|
|
|
var bytes = systemBUID.uuid
|
|
// byte 7 MSBs 4-7 are the uuid version field
|
|
// byte 8 MSBs 6-7 are the variant field
|
|
// everything else *should* be fair game to modify
|
|
// for UUID v4
|
|
bytes.0 = ~bytes.0
|
|
let newSystemBUID = UUID(uuid: bytes).uuidString
|
|
plist["SystemBUID"] = newSystemBUID
|
|
|
|
let hostID = UUID().uuidString
|
|
plist["HostID"] = hostID
|
|
|
|
let record = LockdownClient.PairRecord(
|
|
deviceCertificate: deviceCert,
|
|
hostCertificate: hostCert,
|
|
rootCertificate: rootCert,
|
|
hostID: hostID,
|
|
systemBUID: newSystemBUID
|
|
)
|
|
try await performWithRecovery {
|
|
try lockdownClient.pair(withRecord: record)
|
|
}
|
|
|
|
let data = try PropertyListSerialization.data(
|
|
fromPropertyList: plist, format: plistFormat, options: 0
|
|
)
|
|
|
|
try await updateProgress(to: 1)
|
|
|
|
return data
|
|
}
|
|
|
|
@discardableResult
|
|
public func install(app: URL) async throws -> String {
|
|
try await self.updateStage(to: "Unpacking app", initialProgress: nil)
|
|
|
|
let _tempDir = try TemporaryDirectory(name: "staging")
|
|
let tempDir = _tempDir.url
|
|
defer { withExtendedLifetime(_tempDir) {} }
|
|
|
|
switch app.pathExtension {
|
|
case "ipa":
|
|
try await compressor.decompress(
|
|
file: app,
|
|
in: tempDir,
|
|
progress: { progress in
|
|
self.queueUpdateTask {
|
|
$0.updateProgressIgnoringCancellation(to: progress)
|
|
}
|
|
}
|
|
)
|
|
case "app":
|
|
let payload = tempDir.appendingPathComponent("Payload")
|
|
let dest = payload.appendingPathComponent(app.lastPathComponent)
|
|
try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: false)
|
|
try FileManager.default.copyItem(at: app, to: dest)
|
|
default:
|
|
throw Error.appExtractionFailed
|
|
}
|
|
|
|
try await self.updateProgress(to: 1)
|
|
|
|
let payload = tempDir.appendingPathComponent("Payload")
|
|
guard let appDir = payload.implicitContents.first(where: { $0.pathExtension == "app" })
|
|
else { throw Error.appExtractionFailed }
|
|
|
|
try await updateStage(to: "Preparing device")
|
|
|
|
// TODO: Maybe use `Connection` here instead of creating the lockdown
|
|
// client manually?
|
|
let device = try Device(udid: udid, lookupMode: lookupMode)
|
|
|
|
try await updateProgress(to: configureDevice ? 1/3 : 1/2)
|
|
|
|
// we can't reuse any previously created client because we need to perform a handshake this time
|
|
let lockdownClient = try await performWithRecovery {
|
|
try LockdownClient(
|
|
device: device,
|
|
label: LockdownClient.installerLabel,
|
|
performHandshake: true
|
|
)
|
|
}
|
|
|
|
let deviceName = try lockdownClient.deviceName()
|
|
|
|
let pairingKeys: Data? = if configureDevice {
|
|
try await fetchPairingKeys(with: lockdownClient)
|
|
} else {
|
|
nil
|
|
}
|
|
_ = pairingKeys
|
|
|
|
try await updateProgress(to: 1)
|
|
|
|
let context = try SigningContext(
|
|
auth: auth,
|
|
targetDevice: .init(udid: udid, name: deviceName)
|
|
)
|
|
|
|
let signer = AutoSigner(context: context) { certs in
|
|
await self.delegate?.confirmRevocation(of: certs) ?? false
|
|
}
|
|
let bundleID = try await signer.sign(
|
|
app: appDir,
|
|
status: { @Sendable status in
|
|
self.queueUpdateTask {
|
|
$0.updateStageIgnoringCancellation(to: status)
|
|
}
|
|
},
|
|
progress: { progress in
|
|
self.queueUpdateTask {
|
|
$0.updateProgressIgnoringCancellation(to: progress)
|
|
}
|
|
},
|
|
didProvision: { @Sendable [self] in
|
|
// TODO: reintroduce Superconfig
|
|
_ = self
|
|
}
|
|
)
|
|
|
|
try await self.updateStage(to: "Packaging", initialProgress: nil)
|
|
|
|
let ipa = try await compressor.compress(
|
|
directory: payload,
|
|
progress: { progress in
|
|
self.queueUpdateTask {
|
|
$0.updateProgressIgnoringCancellation(to: progress)
|
|
}
|
|
}
|
|
)
|
|
|
|
try await self.updateProgress(to: 1)
|
|
|
|
let appInstaller = AppInstaller(ipa: ipa, udid: udid, connectionPreferences: .init(lookupMode: lookupMode))
|
|
self.appInstaller = appInstaller
|
|
try await appInstaller.install(
|
|
progress: { stage in
|
|
self.queueUpdateTask {
|
|
$0.updateStageIgnoringCancellation(to: stage.displayName)
|
|
$0.updateProgressIgnoringCancellation(to: stage.displayProgress)
|
|
}
|
|
}
|
|
)
|
|
return bundleID
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|