Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
173 changes: 173 additions & 0 deletions Sources/DDISession.swift
Original file line number Diff line number Diff line change
@@ -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<DDIProgressBox>.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<plist_t?>?
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..<deviceCount { plist_free(devices[index]) }
idevice_data_free(
UnsafeMutableRawPointer(devices).assumingMemoryBound(to: UInt8.self),
UInt(deviceCount * MemoryLayout<plist_t?>.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<IdeviceFfiError>? in
trustcacheData.withUnsafeBytes { trustcacheBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
manifestData.withUnsafeBytes { manifestBuffer -> UnsafeMutablePointer<IdeviceFfiError>? 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<sockaddr_in>.stride),
hostname, pairing, nil, nil,
&tunnel.adapter, &tunnel.handshake)
}
}
}
}
return tunnel
}
}
90 changes: 90 additions & 0 deletions Sources/DeveloperDiskImageService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions Sources/StikJIT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions Sources/StikJITError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)."
}
}
}
Loading