mirror of
https://github.com/StikDebug/StikJIT.git
synced 2026-10-15 20:10:31 +00:00
feat: restructure StikJIT integration workflow
This commit is contained in:
parent
dce417d893
commit
640fac91de
14 changed files with 853 additions and 118 deletions
|
|
@ -3,8 +3,25 @@ import Foundation
|
|||
enum BundledScript {
|
||||
|
||||
static func source(for script: StikJIT.Script) throws -> String {
|
||||
if case .custom(let url) = script {
|
||||
guard url.isFileURL else {
|
||||
throw StikJITError.customScript("expected a file URL")
|
||||
}
|
||||
do {
|
||||
let source = try String(contentsOf: url, encoding: .utf8)
|
||||
guard !source.isEmpty else {
|
||||
throw StikJITError.customScript("the file at \(url.path) is empty")
|
||||
}
|
||||
return source
|
||||
} catch let error as StikJITError {
|
||||
throw error
|
||||
} catch {
|
||||
throw StikJITError.customScript("could not read \(url.path): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
let bundle = Bundle(for: BundleToken.self)
|
||||
guard let url = bundle.url(forResource: script.resourceName, withExtension: "js"),
|
||||
guard let url = bundle.url(forResource: script.name, withExtension: "js"),
|
||||
let source = try? String(contentsOf: url, encoding: .utf8),
|
||||
!source.isEmpty else {
|
||||
throw StikJITError.scriptUnavailable
|
||||
|
|
|
|||
|
|
@ -21,18 +21,55 @@ public struct DDIPaths: Sendable {
|
|||
manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path)
|
||||
}
|
||||
|
||||
var allFilesExist: Bool {
|
||||
var allFilesUsable: Bool {
|
||||
let fileManager = FileManager.default
|
||||
return fileManager.fileExists(atPath: imagePath)
|
||||
&& fileManager.fileExists(atPath: trustcachePath)
|
||||
&& fileManager.fileExists(atPath: manifestPath)
|
||||
return allPaths.allSatisfy { path in
|
||||
guard fileManager.isReadableFile(atPath: path),
|
||||
let attributes = try? fileManager.attributesOfItem(atPath: path),
|
||||
attributes[.type] as? FileAttributeType == .typeRegular,
|
||||
let size = attributes[.size] as? NSNumber else {
|
||||
return false
|
||||
}
|
||||
return size.int64Value > 0
|
||||
}
|
||||
}
|
||||
|
||||
var allPaths: [String] {
|
||||
[imagePath, trustcachePath, manifestPath]
|
||||
}
|
||||
|
||||
func removeCachedFiles() throws {
|
||||
let fileManager = FileManager.default
|
||||
for path in Set(allPaths) where fileManager.fileExists(atPath: path) {
|
||||
try fileManager.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DDIDownloadItem {
|
||||
let name: String
|
||||
let destinationPath: String
|
||||
let url: URL
|
||||
}
|
||||
|
||||
enum DDIDownloadCatalog {
|
||||
private static let baseURL = URL(string: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized")!
|
||||
|
||||
static func items(for paths: DDIPaths) -> [DDIDownloadItem] {
|
||||
[
|
||||
DDIDownloadItem(name: "BuildManifest.plist", destinationPath: paths.manifestPath, url: baseURL.appendingPathComponent("BuildManifest.plist")),
|
||||
DDIDownloadItem(name: "Image.dmg", destinationPath: paths.imagePath, url: baseURL.appendingPathComponent("Image.dmg")),
|
||||
DDIDownloadItem(name: "Image.dmg.trustcache", destinationPath: paths.trustcachePath, url: baseURL.appendingPathComponent("Image.dmg.trustcache")),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.4, *)
|
||||
public actor DeveloperDiskImageService {
|
||||
|
||||
public static let shared = DeveloperDiskImageService()
|
||||
private static let sharedInstance = DeveloperDiskImageService()
|
||||
|
||||
public static var shared: DeveloperDiskImageService { sharedInstance }
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
|
|
@ -40,45 +77,29 @@ public actor DeveloperDiskImageService {
|
|||
self.session = session
|
||||
}
|
||||
|
||||
private struct DownloadItem {
|
||||
let name: String
|
||||
let destinationPath: String
|
||||
let urlString: String
|
||||
}
|
||||
|
||||
private static let baseURL = "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized"
|
||||
|
||||
public func downloadIfNeeded(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
|
||||
guard !paths.allFilesExist else { return }
|
||||
guard !paths.allFilesUsable else { return }
|
||||
try await download(to: paths, progress: progress)
|
||||
}
|
||||
|
||||
public func download(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
|
||||
let items = [
|
||||
DownloadItem(name: "BuildManifest.plist", destinationPath: paths.manifestPath, urlString: "\(Self.baseURL)/BuildManifest.plist"),
|
||||
DownloadItem(name: "Image.dmg", destinationPath: paths.imagePath, urlString: "\(Self.baseURL)/Image.dmg"),
|
||||
DownloadItem(name: "Image.dmg.trustcache", destinationPath: paths.trustcachePath, urlString: "\(Self.baseURL)/Image.dmg.trustcache"),
|
||||
]
|
||||
let items = DDIDownloadCatalog.items(for: paths)
|
||||
|
||||
let total = Double(items.count)
|
||||
for (index, item) in items.enumerated() {
|
||||
progress(Double(index) / total, "Downloading \(item.name)...")
|
||||
try await downloadFile(from: item.urlString, to: URL(fileURLWithPath: item.destinationPath))
|
||||
try await downloadFile(from: item.url, to: URL(fileURLWithPath: item.destinationPath))
|
||||
progress(Double(index + 1) / total, "\(item.name) ready")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadFile(from urlString: String, to destinationURL: URL) async throws {
|
||||
guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else {
|
||||
throw StikJITError.ddiDownload("invalid URL \(urlString)")
|
||||
}
|
||||
|
||||
private func downloadFile(from url: URL, to destinationURL: URL) async throws {
|
||||
let (temporaryURL, response) = try await session.download(from: url)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw StikJITError.ddiDownload("invalid response for \(urlString)")
|
||||
throw StikJITError.ddiDownload("invalid response for \(url.absoluteString)")
|
||||
}
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(urlString)")
|
||||
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(url.absoluteString)")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
|
|
|
|||
56
Sources/EndpointProbe.swift
Normal file
56
Sources/EndpointProbe.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
import Network
|
||||
|
||||
enum EndpointProbe {
|
||||
private static let queue = DispatchQueue(label: "com.stik.StikJIT.endpoint-probe")
|
||||
|
||||
static func failureReason(address: String, port: UInt16, timeout: TimeInterval) -> String? {
|
||||
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let endpointPort = NWEndpoint.Port(rawValue: port) else {
|
||||
return "The configured device endpoint is invalid."
|
||||
}
|
||||
|
||||
let state = ProbeState()
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let connection = NWConnection(host: NWEndpoint.Host(address), port: endpointPort, using: .tcp)
|
||||
connection.stateUpdateHandler = { connectionState in
|
||||
switch connectionState {
|
||||
case .ready:
|
||||
if state.finish(reason: nil) { semaphore.signal() }
|
||||
case .failed(let error):
|
||||
if state.finish(reason: error.localizedDescription) { semaphore.signal() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
|
||||
if semaphore.wait(timeout: .now() + max(timeout, 0.1)) == .timedOut {
|
||||
_ = state.finish(reason: "Timed out connecting to \(address):\(port).")
|
||||
}
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
return state.reason
|
||||
}
|
||||
}
|
||||
|
||||
private final class ProbeState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var isFinished = false
|
||||
private var storedReason: String?
|
||||
|
||||
var reason: String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storedReason
|
||||
}
|
||||
|
||||
func finish(reason: String?) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !isFinished else { return false }
|
||||
isFinished = true
|
||||
storedReason = reason
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,11 @@ final class JITSession {
|
|||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func enableJIT(targetPID: Int32, script: StikJIT.Script, progress: @escaping (String) -> Void) throws {
|
||||
func enableJIT(targetPID: Int32,
|
||||
script: StikJIT.Script,
|
||||
forceScript: Bool,
|
||||
txmPresence: TXMPresence,
|
||||
progress: @escaping (String) -> Void) throws {
|
||||
let tunnel = try makeTunnel()
|
||||
defer { tunnel.free() }
|
||||
|
||||
|
|
@ -42,15 +46,18 @@ final class JITSession {
|
|||
_ = try? sendCommand("QStartNoAckMode", over: debugProxy)
|
||||
debug_proxy_set_ack_mode(debugProxy, 0)
|
||||
|
||||
if ProcessInfo.processInfo.hasTXM {
|
||||
switch (forceScript, txmPresence) {
|
||||
case (true, _), (false, .present):
|
||||
try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, script: script, progress: progress).run()
|
||||
} else {
|
||||
case (false, .absent):
|
||||
try attachWithoutScript(targetPID: targetPID, debugProxy: debugProxy, progress: progress)
|
||||
case (false, .unknown):
|
||||
throw StikJITError.txmDetectionUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
private func attachWithoutScript(targetPID: Int32, debugProxy: OpaquePointer, progress: (String) -> Void) throws {
|
||||
progress("Attaching to pid \(targetPID). This device has no TXM, so the attach alone enables JIT and the script is skipped.")
|
||||
progress("Attaching to pid \(targetPID). TXM is not present, so the attach alone enables JIT and the script is skipped.")
|
||||
_ = try sendCommand("vAttach;\(String(targetPID, radix: 16))", over: debugProxy)
|
||||
_ = try? sendCommand("D", over: debugProxy)
|
||||
progress("JIT enabled (debugger attached and detached).")
|
||||
|
|
|
|||
|
|
@ -1,58 +1,42 @@
|
|||
import Foundation
|
||||
@_implementationOnly import StikJITIOKit
|
||||
|
||||
extension ProcessInfo {
|
||||
var hasTXM: Bool {
|
||||
let hardware = Self.hardwareIdentifier()
|
||||
enum TXMPresence {
|
||||
case present
|
||||
case absent
|
||||
case unknown
|
||||
|
||||
if Self.isIOS27OrNewer {
|
||||
return hardware != "iPad8,11" && hardware != "iPad8,12"
|
||||
init(isPresent: Bool?) {
|
||||
guard let isPresent else {
|
||||
self = .unknown
|
||||
return
|
||||
}
|
||||
|
||||
if Self.isIOS26OrNewer {
|
||||
return Self.hasTXMSupport(hardwareIdentifier: hardware)
|
||||
}
|
||||
|
||||
return false
|
||||
self = isPresent ? .present : .absent
|
||||
}
|
||||
|
||||
private static func hasTXMSupport(hardwareIdentifier: String) -> Bool {
|
||||
let firstTXM = 14.2
|
||||
let iPadTXM = 14.5
|
||||
guard let version = deviceVersion(from: hardwareIdentifier) else {
|
||||
return false
|
||||
var isPresent: Bool? {
|
||||
switch self {
|
||||
case .present: return true
|
||||
case .absent: return false
|
||||
case .unknown: return nil
|
||||
}
|
||||
if hardwareIdentifier.hasPrefix("iPad") {
|
||||
return version >= iPadTXM
|
||||
}
|
||||
return version >= firstTXM
|
||||
}
|
||||
|
||||
private static func deviceVersion(from identifier: String) -> Double? {
|
||||
func version(forPrefix prefix: String) -> Double? {
|
||||
guard identifier.hasPrefix(prefix) else { return nil }
|
||||
let parts = identifier.dropFirst(prefix.count).split(separator: ",")
|
||||
guard parts.count == 2, let major = Int(parts[0]), let minor = Int(parts[1]) else { return nil }
|
||||
return Double("\(major).\(minor)")
|
||||
}
|
||||
return version(forPrefix: "iPhone") ?? version(forPrefix: "iPad")
|
||||
}
|
||||
|
||||
private static func hardwareIdentifier() -> String {
|
||||
var size = 0
|
||||
sysctlbyname("hw.machine", nil, &size, nil, 0)
|
||||
guard size > 0 else { return "" }
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
sysctlbyname("hw.machine", &buffer, &size, nil, 0)
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
private static var isIOS26OrNewer: Bool {
|
||||
if #available(iOS 26.0, *) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static var isIOS27OrNewer: Bool {
|
||||
if #available(iOS 27.0, *) { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension ProcessInfo {
|
||||
var txmPresence: TXMPresence {
|
||||
let memoryMap = IORegistryEntryFromPath(kIOMainPortDefault, "IODeviceTree:/chosen/memory-map")
|
||||
guard memoryMap != 0 else { return .unknown }
|
||||
|
||||
let keysCF = IORegistryEntryCreateCFProperty(
|
||||
memoryMap,
|
||||
kIORegistryEntryPropertyKeysKey as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue()
|
||||
IOObjectRelease(memoryMap)
|
||||
guard let keys = keysCF as? [String] else { return .unknown }
|
||||
|
||||
return keys.contains("TXM") ? .present : .absent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ final class ScriptRunner {
|
|||
private let script: StikJIT.Script
|
||||
private let progress: (String) -> Void
|
||||
private var context: JSContext?
|
||||
private var executionError: StikJITError?
|
||||
|
||||
init(targetPID: Int32, debugProxy: OpaquePointer, script: StikJIT.Script, progress: @escaping (String) -> Void) {
|
||||
self.targetPID = targetPID
|
||||
|
|
@ -28,7 +29,8 @@ final class ScriptRunner {
|
|||
self.context = context
|
||||
|
||||
context.exceptionHandler = { [weak self] _, value in
|
||||
self?.progress("JS exception: \(value?.toString() ?? "<unknown>")")
|
||||
let detail = value?.toString() ?? "unknown JavaScript exception"
|
||||
self?.recordExecutionError(.scriptExecution(detail))
|
||||
}
|
||||
|
||||
let getPID: @convention(block) () -> Int = { [targetPID] in Int(targetPID) }
|
||||
|
|
@ -38,7 +40,7 @@ final class ScriptRunner {
|
|||
}
|
||||
let prepare: @convention(block) (Double, Double) -> String = { [weak self] address, length in
|
||||
guard let self else { return "" }
|
||||
return self.prepareMemoryRegion(UInt64(address), length: UInt64(length)) ?? "OK"
|
||||
return self.prepareMemoryRegion(UInt64(address), length: UInt64(length)) ?? ""
|
||||
}
|
||||
let log: @convention(block) (JSValue?) -> Void = { [weak self] value in
|
||||
self?.progress(value?.toString() ?? "")
|
||||
|
|
@ -49,8 +51,9 @@ final class ScriptRunner {
|
|||
context.setObject(prepare, forKeyedSubscript: "prepare_memory_region" as NSString)
|
||||
context.setObject(log, forKeyedSubscript: "log" as NSString)
|
||||
|
||||
progress("Running \(script.resourceName).js against pid \(targetPID)…")
|
||||
progress("Running \(script.name) against pid \(targetPID)…")
|
||||
context.evaluateScript(source)
|
||||
if let executionError { throw executionError }
|
||||
progress("JIT script finished (region blessed, detached).")
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +62,7 @@ final class ScriptRunner {
|
|||
defer { debugserver_command_free(handle) }
|
||||
var response: UnsafeMutablePointer<CChar>?
|
||||
if let error = debug_proxy_send_command(debugProxy, handle, &response) {
|
||||
progress("send_command error: \(IdeviceFFI.consume(error, fallback: "send_command").localizedDescription)")
|
||||
recordExecutionError(IdeviceFFI.consume(error, fallback: "send_command"))
|
||||
return nil
|
||||
}
|
||||
guard let response else { return "" }
|
||||
|
|
@ -83,7 +86,7 @@ final class ScriptRunner {
|
|||
return debug_proxy_send_raw(debugProxy, base.advanced(by: byteOffset), UInt(byteCount))
|
||||
}
|
||||
if let sendError {
|
||||
progress("page bless batch failed @0x\(String(address, radix: 16)): \(IdeviceFFI.consume(sendError, fallback: "debug_proxy_send_raw").localizedDescription)")
|
||||
recordExecutionError(IdeviceFFI.consume(sendError, fallback: "debug_proxy_send_raw"))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +97,7 @@ final class ScriptRunner {
|
|||
idevice_string_free(response)
|
||||
}
|
||||
if let readError {
|
||||
progress("page bless batch failed @0x\(String(address, radix: 16)): \(IdeviceFFI.consume(readError, fallback: "debug_proxy_read_response").localizedDescription)")
|
||||
recordExecutionError(IdeviceFFI.consume(readError, fallback: "debug_proxy_read_response"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +107,13 @@ final class ScriptRunner {
|
|||
return "OK"
|
||||
}
|
||||
|
||||
private func recordExecutionError(_ error: StikJITError) {
|
||||
if executionError == nil {
|
||||
executionError = error
|
||||
progress(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeBlessCommands(startAddress: UInt64, pageCount: Int) -> [UInt8] {
|
||||
var buffer = [UInt8](repeating: 0, count: pageCount * jitPageCommandLength)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,35 +8,163 @@ public enum StikJIT {
|
|||
|
||||
case legacy
|
||||
|
||||
var resourceName: String {
|
||||
case custom(URL)
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .universal: return "universal"
|
||||
case .legacy: return "legacy"
|
||||
case .custom(let url): return url.lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceSecurityState: Sendable {
|
||||
|
||||
public let isTXMPresent: Bool?
|
||||
|
||||
public init(isTXMPresent: Bool?) {
|
||||
self.isTXMPresent = isTXMPresent
|
||||
}
|
||||
}
|
||||
|
||||
public enum PreparationStage: Sendable {
|
||||
|
||||
case checkingReachability
|
||||
|
||||
case checkingDDI
|
||||
|
||||
case downloadingDDI(fraction: Double, status: String)
|
||||
|
||||
case mountingDDI(fraction: Double)
|
||||
|
||||
case verifyingDDI
|
||||
|
||||
case ready
|
||||
}
|
||||
|
||||
public enum JITReadiness: Sendable {
|
||||
|
||||
case unreachable(reason: String)
|
||||
|
||||
case preparationFailed(reason: String)
|
||||
|
||||
case ready(DeviceSecurityState)
|
||||
|
||||
public var isReady: Bool {
|
||||
if case .ready = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
public var deviceAddress: String
|
||||
|
||||
public var rsdPort: UInt16
|
||||
|
||||
public init(deviceAddress: String = "10.7.0.1", rsdPort: UInt16 = 49152) {
|
||||
public var connectionTimeout: TimeInterval
|
||||
|
||||
public init(deviceAddress: String = "10.7.0.1",
|
||||
rsdPort: UInt16 = 49152,
|
||||
connectionTimeout: TimeInterval = 5) {
|
||||
self.deviceAddress = deviceAddress
|
||||
self.rsdPort = rsdPort
|
||||
self.connectionTimeout = connectionTimeout
|
||||
}
|
||||
|
||||
public static let `default` = Configuration()
|
||||
public static var `default`: Configuration { Configuration() }
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "Use enableJIT with ddiPaths to prepare the device before enabling JIT.")
|
||||
public static func enableJIT(targetPID: Int32,
|
||||
pairingFile: URL,
|
||||
configuration: Configuration = .default,
|
||||
script: Script = .universal,
|
||||
forceScript: Bool = false,
|
||||
progress: @escaping (String) -> Void = { _ in }) throws {
|
||||
let txmPresence = ProcessInfo.processInfo.txmPresence
|
||||
let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration)
|
||||
try session.enableJIT(targetPID: targetPID, script: script, progress: progress)
|
||||
try session.enableJIT(targetPID: targetPID,
|
||||
script: script,
|
||||
forceScript: forceScript,
|
||||
txmPresence: txmPresence,
|
||||
progress: progress)
|
||||
}
|
||||
|
||||
public static func enableJIT(targetPID: Int32,
|
||||
pairingFile: URL,
|
||||
ddiPaths: DDIPaths,
|
||||
configuration: Configuration = .default,
|
||||
script: Script = .universal,
|
||||
forceScript: Bool = false,
|
||||
preparationProgress: @escaping (PreparationStage) -> Void = { _ in },
|
||||
progress: @escaping (String) -> Void = { _ in }) throws {
|
||||
let readiness = prepareDevice(pairingFile: pairingFile,
|
||||
paths: ddiPaths,
|
||||
configuration: configuration,
|
||||
progress: preparationProgress)
|
||||
let securityState: DeviceSecurityState
|
||||
switch readiness {
|
||||
case .ready(let readySecurityState):
|
||||
securityState = readySecurityState
|
||||
case .unreachable(let reason), .preparationFailed(let reason):
|
||||
throw StikJITError.deviceNotReady(reason)
|
||||
}
|
||||
|
||||
let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration)
|
||||
try session.enableJIT(targetPID: targetPID,
|
||||
script: script,
|
||||
forceScript: forceScript,
|
||||
txmPresence: TXMPresence(isPresent: securityState.isTXMPresent),
|
||||
progress: progress)
|
||||
}
|
||||
|
||||
public static var isTXMPresent: Bool? {
|
||||
ProcessInfo.processInfo.txmPresence.isPresent
|
||||
}
|
||||
|
||||
public static func prepareDevice(pairingFile: URL,
|
||||
paths: DDIPaths,
|
||||
configuration: Configuration = .default,
|
||||
progress: @escaping (PreparationStage) -> Void = { _ in }) -> JITReadiness {
|
||||
progress(.checkingReachability)
|
||||
if let reason = EndpointProbe.failureReason(address: configuration.deviceAddress,
|
||||
port: configuration.rsdPort,
|
||||
timeout: configuration.connectionTimeout) {
|
||||
return .unreachable(reason: reason)
|
||||
}
|
||||
|
||||
do {
|
||||
progress(.checkingDDI)
|
||||
let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration)
|
||||
if try !session.isMounted() {
|
||||
if !paths.allFilesUsable {
|
||||
try SynchronousDDIDownloader.download(to: paths) { fraction, status in
|
||||
progress(.downloadingDDI(fraction: fraction, status: status))
|
||||
}
|
||||
}
|
||||
try session.mountDDI(imagePath: paths.imagePath,
|
||||
trustcachePath: paths.trustcachePath,
|
||||
manifestPath: paths.manifestPath) { fraction in
|
||||
progress(.mountingDDI(fraction: fraction))
|
||||
}
|
||||
progress(.verifyingDDI)
|
||||
guard try session.isMounted() else {
|
||||
throw StikJITError.ddiNotMounted
|
||||
}
|
||||
}
|
||||
|
||||
let securityState = DeviceSecurityState(isTXMPresent: isTXMPresent)
|
||||
progress(.ready)
|
||||
return .ready(securityState)
|
||||
} catch {
|
||||
return .preparationFailed(reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public static func resetCachedDDI(at paths: DDIPaths) throws {
|
||||
try paths.removeCachedFiles()
|
||||
}
|
||||
|
||||
public static func isDDIMounted(pairingFile: URL,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ public enum StikJITError: Error, LocalizedError {
|
|||
|
||||
case ddiFilesMissing(String)
|
||||
|
||||
case ddiNotMounted
|
||||
|
||||
case deviceNotReady(String)
|
||||
|
||||
case txmDetectionUnavailable
|
||||
|
||||
case customScript(String)
|
||||
|
||||
case scriptExecution(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .pairingFile(let detail):
|
||||
|
|
@ -28,6 +38,16 @@ public enum StikJITError: Error, LocalizedError {
|
|||
return "Failed to download Developer Disk Image: \(detail)."
|
||||
case .ddiFilesMissing(let detail):
|
||||
return "Developer Disk Image files missing: \(detail)."
|
||||
case .ddiNotMounted:
|
||||
return "The Developer Disk Image was not mounted after the mount operation completed."
|
||||
case .deviceNotReady(let detail):
|
||||
return "The device is not ready for JIT: \(detail)"
|
||||
case .txmDetectionUnavailable:
|
||||
return "Whether TXM is present could not be determined. Enable forced script execution to bypass this check."
|
||||
case .customScript(let detail):
|
||||
return "Custom JIT script error: \(detail)."
|
||||
case .scriptExecution(let detail):
|
||||
return "JIT script failed: \(detail)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
Sources/SynchronousDDIDownloader.swift
Normal file
57
Sources/SynchronousDDIDownloader.swift
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Foundation
|
||||
|
||||
enum SynchronousDDIDownloader {
|
||||
static func download(to paths: DDIPaths,
|
||||
progress: @escaping (Double, String) -> Void) throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 120
|
||||
configuration.timeoutIntervalForResource = 600
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let items = DDIDownloadCatalog.items(for: paths)
|
||||
let total = Double(items.count)
|
||||
for (index, item) in items.enumerated() {
|
||||
progress(Double(index) / total, "Downloading \(item.name)...")
|
||||
try download(item, session: session)
|
||||
progress(Double(index + 1) / total, "\(item.name) ready")
|
||||
}
|
||||
}
|
||||
|
||||
private static func download(_ item: DDIDownloadItem, session: URLSession) throws {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let result = DownloadResult()
|
||||
let task = session.downloadTask(with: item.url) { temporaryURL, response, error in
|
||||
defer { semaphore.signal() }
|
||||
do {
|
||||
if let error { throw error }
|
||||
guard let temporaryURL, let httpResponse = response as? HTTPURLResponse else {
|
||||
throw StikJITError.ddiDownload("invalid response for \(item.url.absoluteString)")
|
||||
}
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(item.url.absoluteString)")
|
||||
}
|
||||
|
||||
let destinationURL = URL(fileURLWithPath: item.destinationPath)
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: temporaryURL, to: destinationURL)
|
||||
} catch {
|
||||
result.error = error
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
if let error = result.error {
|
||||
if let stikJITError = error as? StikJITError { throw stikJITError }
|
||||
throw StikJITError.ddiDownload(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class DownloadResult: @unchecked Sendable {
|
||||
var error: Error?
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue