diff --git a/README.md b/README.md index a39b9e21d5..e9c8e6788b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ JIT cannot be enabled in-process, since a process that attaches a debugger to it Because it needs a separate process, you call StikJIT from a small app extension that your app launches and hands the target app's PID to (for example over XPC). StikJIT does not include that extension, its launch, or the pairing file; your app provides those. +The device needs LocalDevVPN connected, and either Wi-Fi or Airplane Mode enabled, before JIT can be enabled or the DDI can be mounted. + Add `StikJIT.xcframework` to the extension (Embed & Sign), then call it off the main thread: ```swift @@ -22,6 +24,25 @@ try StikJIT.enableJIT( It blocks until done and throws `StikJITError` on failure. Pass `configuration:` to override the tunnel endpoint (defaults to `10.7.0.1:49152`). Pass `script:` to select the bundled JS used to drive the JIT-enabling exchange on devices with TXM — `.universal` (default) or `.legacy` based on your app's needs. +### Developer Disk Image + +The device must have the personalized Developer Disk Image (DDI) mounted before JIT can be enabled — `enableJIT` will fail otherwise. A mount persists until the device reboots, so it only needs to be (re)done once per boot, not on every launch. Mounting has no dependency on the extension process, so it can be done from anywhere in your app (e.g. on launch or in the background) rather than from the same extension that calls `enableJIT`: + +```swift +import StikJIT + +let paths = DDIPaths.default(in: documentsDirectory) + +if try !StikJIT.isDDIMounted(pairingFile: pairingFileURL) { + try await StikJIT.downloadDDIIfNeeded(to: paths) { fraction, status in + print("[DDI] \(status) (\(Int(fraction * 100))%)") + } + try StikJIT.mountDDI(pairingFile: pairingFileURL, paths: paths) { fraction in + print("[DDI] mounting \(Int(fraction * 100))%") + } +} +``` + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/StephenDev0/StikJIT) ## Build diff --git a/Sources/DDISession.swift b/Sources/DDISession.swift new file mode 100644 index 0000000000..f418783bc7 --- /dev/null +++ b/Sources/DDISession.swift @@ -0,0 +1,173 @@ +import Foundation +@_implementationOnly import idevice + +private func ddiMountProgressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { + guard let context else { return } + let progressHandler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let fraction = total > 0 ? Double(progress) / Double(total) : 0 + progressHandler.handler(fraction) +} + +private final class DDIProgressBox { + let handler: (Double) -> Void + init(_ handler: @escaping (Double) -> Void) { self.handler = handler } +} + +final class DDISession { + + private struct Tunnel { + var adapter: OpaquePointer? + var handshake: OpaquePointer? + func free() { + if let handshake { rsd_handshake_free(handshake) } + if let adapter { adapter_free(adapter) } + } + } + + private let pairingFilePath: String + private let configuration: StikJIT.Configuration + + init(pairingFilePath: String, configuration: StikJIT.Configuration) { + self.pairingFilePath = pairingFilePath + self.configuration = configuration + } + + func isMounted() throws -> Bool { + let tunnel = try makeTunnel() + defer { tunnel.free() } + + var client: OpaquePointer? + try IdeviceFFI.check("failed to connect to image mounter") { + image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client) + } + guard let client else { throw StikJITError.debugProxyUnavailable } + defer { image_mounter_free(client) } + + var devices: UnsafeMutablePointer? + var deviceCount = 0 + try IdeviceFFI.check("failed to fetch mounted devices") { + image_mounter_copy_devices(client, &devices, &deviceCount) + } + if let devices { + for index in 0...stride)) + } + return deviceCount > 0 + } + + func mountDDI(imagePath: String, trustcachePath: String, manifestPath: String, progress: @escaping (Double) -> Void) throws { + let imageData = try mappedFileData(atPath: imagePath, description: "developer disk image") + let trustcacheData = try mappedFileData(atPath: trustcachePath, description: "developer disk image trust cache") + let manifestData = try mappedFileData(atPath: manifestPath, description: "developer disk image manifest") + + let tunnel = try makeTunnel() + defer { tunnel.free() } + + let uniqueChipID = try fetchUniqueChipID(over: tunnel) + + var client: OpaquePointer? + try IdeviceFFI.check("failed to connect to image mounter") { + image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client) + } + guard let client else { throw StikJITError.debugProxyUnavailable } + defer { image_mounter_free(client) } + + let progressBox = DDIProgressBox(progress) + let context = Unmanaged.passUnretained(progressBox).toOpaque() + + try IdeviceFFI.check("failed to mount personalized developer disk image") { + imageData.withUnsafeBytes { imageBuffer -> UnsafeMutablePointer? in + trustcacheData.withUnsafeBytes { trustcacheBuffer -> UnsafeMutablePointer? in + manifestData.withUnsafeBytes { manifestBuffer -> UnsafeMutablePointer? in + image_mounter_mount_personalized_with_callback_rsd( + client, + tunnel.adapter, + tunnel.handshake, + imageBuffer.bindMemory(to: UInt8.self).baseAddress, + imageData.count, + trustcacheBuffer.bindMemory(to: UInt8.self).baseAddress, + trustcacheData.count, + manifestBuffer.bindMemory(to: UInt8.self).baseAddress, + manifestData.count, + nil, + uniqueChipID, + ddiMountProgressCallback, + context) + } + } + } + } + } + + private func mappedFileData(atPath path: String, description: String) throws -> Data { + guard FileManager.default.fileExists(atPath: path) else { + throw StikJITError.ddiFilesMissing("\(description) not found at \(path)") + } + let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + guard !data.isEmpty else { + throw StikJITError.ddiFilesMissing("\(description) is empty at \(path)") + } + return data + } + + private func fetchUniqueChipID(over tunnel: Tunnel) throws -> UInt64 { + var client: OpaquePointer? + try IdeviceFFI.check("failed to connect to lockdownd") { + lockdownd_connect_rsd(tunnel.adapter, tunnel.handshake, &client) + } + guard let client else { throw StikJITError.debugProxyUnavailable } + defer { lockdownd_client_free(client) } + + var uniqueChipIDPlist: plist_t? + try IdeviceFFI.check("failed to query UniqueChipID") { + "UniqueChipID".withCString { lockdownd_get_value(client, $0, nil, &uniqueChipIDPlist) } + } + guard let uniqueChipIDPlist else { + throw StikJITError.device(code: -1, subCode: 0, message: "UniqueChipID was not returned by lockdownd") + } + defer { plist_free(uniqueChipIDPlist) } + + var value: UInt64 = 0 + plist_get_uint_val(uniqueChipIDPlist, &value) + return value + } + + private func openPairingFile() throws -> OpaquePointer { + guard !pairingFilePath.isEmpty, FileManager.default.fileExists(atPath: pairingFilePath) else { + throw StikJITError.pairingFile("not found at \(pairingFilePath)") + } + var handle: OpaquePointer? + try IdeviceFFI.check("failed to read pairing file") { + pairingFilePath.withCString { rp_pairing_file_read($0, &handle) } + } + guard let handle else { throw StikJITError.pairingFile("unreadable at \(pairingFilePath)") } + return handle + } + + private func makeTunnel() throws -> Tunnel { + let pairing = try openPairingFile() + defer { rp_pairing_file_free(pairing) } + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = configuration.rsdPort.bigEndian + _ = configuration.deviceAddress.withCString { inet_pton(AF_INET, $0, &address.sin_addr) } + + var tunnel = Tunnel() + try IdeviceFFI.check("failed to create RSD tunnel") { + "StikJIT".withCString { hostname in + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + tunnel_create_rppairing( + sa, socklen_t(MemoryLayout.stride), + hostname, pairing, nil, nil, + &tunnel.adapter, &tunnel.handshake) + } + } + } + } + return tunnel + } +} diff --git a/Sources/DeveloperDiskImageService.swift b/Sources/DeveloperDiskImageService.swift new file mode 100644 index 0000000000..d21ee4468b --- /dev/null +++ b/Sources/DeveloperDiskImageService.swift @@ -0,0 +1,90 @@ +import Foundation + +public struct DDIPaths: Sendable { + + public var imagePath: String + + public var trustcachePath: String + + public var manifestPath: String + + public init(imagePath: String, trustcachePath: String, manifestPath: String) { + self.imagePath = imagePath + self.trustcachePath = trustcachePath + self.manifestPath = manifestPath + } + + public static func `default`(in directory: URL) -> DDIPaths { + DDIPaths( + imagePath: directory.appendingPathComponent("DDI/Image.dmg").path, + trustcachePath: directory.appendingPathComponent("DDI/Image.dmg.trustcache").path, + manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path) + } + + var allFilesExist: Bool { + let fileManager = FileManager.default + return fileManager.fileExists(atPath: imagePath) + && fileManager.fileExists(atPath: trustcachePath) + && fileManager.fileExists(atPath: manifestPath) + } +} + +public actor DeveloperDiskImageService { + + public static let shared = DeveloperDiskImageService() + + private let session: URLSession + + public init(session: URLSession = .shared) { + 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 } + 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 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)) + 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)") + } + + let (temporaryURL, response) = try await session.download(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw StikJITError.ddiDownload("invalid response for \(urlString)") + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(urlString)") + } + + 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) + } +} diff --git a/Sources/StikJIT.swift b/Sources/StikJIT.swift index 59adae4404..f24329e3c2 100644 --- a/Sources/StikJIT.swift +++ b/Sources/StikJIT.swift @@ -38,4 +38,25 @@ public enum StikJIT { let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration) try session.enableJIT(targetPID: targetPID, script: script, progress: progress) } + + public static func isDDIMounted(pairingFile: URL, + configuration: Configuration = .default) throws -> Bool { + try DDISession(pairingFilePath: pairingFile.path, configuration: configuration).isMounted() + } + + public static func downloadDDIIfNeeded(to paths: DDIPaths, + progress: @escaping (Double, String) -> Void = { _, _ in }) async throws { + try await DeveloperDiskImageService.shared.downloadIfNeeded(to: paths, progress: progress) + } + + public static func mountDDI(pairingFile: URL, + configuration: Configuration = .default, + paths: DDIPaths, + progress: @escaping (Double) -> Void = { _ in }) throws { + let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration) + try session.mountDDI(imagePath: paths.imagePath, + trustcachePath: paths.trustcachePath, + manifestPath: paths.manifestPath, + progress: progress) + } } diff --git a/Sources/StikJITError.swift b/Sources/StikJITError.swift index d99926c10f..a0b30e9080 100644 --- a/Sources/StikJITError.swift +++ b/Sources/StikJITError.swift @@ -10,6 +10,10 @@ public enum StikJITError: Error, LocalizedError { case device(code: Int32, subCode: Int32, message: String) + case ddiDownload(String) + + case ddiFilesMissing(String) + public var errorDescription: String? { switch self { case .pairingFile(let detail): @@ -20,6 +24,10 @@ public enum StikJITError: Error, LocalizedError { return "Failed to establish a debugserver connection to the target process." case .device(let code, let subCode, let message): return "\(message) (idevice code \(code)/\(subCode))." + case .ddiDownload(let detail): + return "Failed to download Developer Disk Image: \(detail)." + case .ddiFilesMissing(let detail): + return "Developer Disk Image files missing: \(detail)." } } }