feat: restructure StikJIT integration workflow

This commit is contained in:
CelloSerenity 2026-08-15 09:50:05 -06:00
parent dce417d893
commit 640fac91de
14 changed files with 853 additions and 118 deletions

View file

@ -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

View file

@ -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

View 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
}
}

View file

@ -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).")

View file

@ -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
}
}

View file

@ -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)

View file

@ -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,

View file

@ -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)."
}
}
}

View 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?
}