From 640fac91de403fdb85a3778aa0bbb7f30737b74c Mon Sep 17 00:00:00 2001
From: CelloSerenity <195480169+CelloSerenity@users.noreply.github.com>
Date: Sat, 15 Aug 2026 09:50:05 -0600
Subject: [PATCH] feat: restructure StikJIT integration workflow
---
INTEGRATION.md | 403 ++++++++++++++++++++++++
README.md | 72 +++--
Sources/BundledScript.swift | 19 +-
Sources/DeveloperDiskImageService.swift | 75 +++--
Sources/EndpointProbe.swift | 56 ++++
Sources/JITSession.swift | 15 +-
Sources/ProcessInfo+TXM.swift | 82 ++---
Sources/ScriptRunner.swift | 22 +-
Sources/StikJIT.swift | 136 +++++++-
Sources/StikJITError.swift | 20 ++
Sources/SynchronousDDIDownloader.swift | 57 ++++
iokit/StikJITIOKit.h | 1 +
iokit/module.modulemap | 4 +
project.yml | 9 +-
14 files changed, 853 insertions(+), 118 deletions(-)
create mode 100644 INTEGRATION.md
create mode 100644 Sources/EndpointProbe.swift
create mode 100644 Sources/SynchronousDDIDownloader.swift
create mode 100644 iokit/StikJITIOKit.h
create mode 100644 iokit/module.modulemap
diff --git a/INTEGRATION.md b/INTEGRATION.md
new file mode 100644
index 0000000..31437b5
--- /dev/null
+++ b/INTEGRATION.md
@@ -0,0 +1,403 @@
+# Integrating StikJIT
+
+This guide covers both the app-side iOS 26 JIT protocol and the available ways to acquire JIT. The process receiving JIT must have `get-task-allow`.
+
+## Choose the path you need
+
+This guide separates two different jobs:
+
+- **Part 1: Add iOS 26 JIT support** changes the app's JIT memory allocator so it can cooperate with a script where TXM/SPTM is present.
+- **Part 2: Integrate StikDebug and StikJIT** adds the StikDebug URL scheme and optional Built-in StikJIT to an app that already works with iOS 26 JIT and manual StikDebug activation.
+
+If the app already enables JIT successfully after the user manually selects it and its script in StikDebug, skip Part 1. Record the script filename and complete Part 2. Part 2 does not require changing a working JIT allocator.
+
+If the app does not already have script-based iOS 26 JIT, complete Part 1 first and then Part 2.
+
+# Part 1: Add iOS 26 JIT support
+
+On a device where TXM/SPTM is not present, attaching and detaching the debugger is enough to enable JIT. Where TXM/SPTM is present, the debugger flag alone is not enough: each executable memory region must be prepared through the debug connection before the app executes code from it.
+
+StikJIT and StikDebug handle the debugger side of this process. The host app's JIT memory allocator must implement the matching breakpoint protocol. Selecting `universal.js` does not retrofit that protocol into an arbitrary JIT engine.
+
+## Implement the universal protocol (recommended)
+
+The universal script recognizes these calls on arm64:
+
+```c
+__attribute__((noinline, optnone, naked))
+void JIT26Detach(void) {
+ __asm__(
+ "mov x16, #0\n"
+ "brk #0xf00d\n"
+ "ret\n"
+ );
+}
+
+__attribute__((noinline, optnone, naked))
+void* JIT26PrepareRegion(void* address, size_t length) {
+ __asm__(
+ "mov x16, #1\n"
+ "brk #0xf00d\n"
+ "ret\n"
+ );
+}
+```
+
+The arguments arrive in `x0` and `x1`. `JIT26PrepareRegion` advances past the breakpoint, prepares the RX mapping, and returns its address in `x0`. Passing a null address asks the debug server to allocate the RX mapping. If the JIT engine creates the mapping itself, pass its address and size instead. The engine is still responsible for creating any writable alias it needs and for preserving W^X.
+
+Use this order where TXM/SPTM is present:
+
+1. Start the chosen JIT acquisition method. Its universal script attaches and waits for breakpoint calls.
+2. Wait until `CS_DEBUGGED` appears in the host before executing any breakpoint call. A `brk` without the script attached will crash the process.
+3. Allocate or request every initial RX region and call `JIT26PrepareRegion` for each one.
+4. Create the writable aliases and finish initializing the JIT allocator.
+5. Call `JIT26Detach`.
+6. Mark JIT ready only after the prepare calls and detach call have returned successfully.
+
+Preallocate executable regions before detaching. A region introduced later cannot be prepared by a script that has already detached; reconnect and repeat the protocol if the engine must add one.
+
+## Keep the legacy protocol only for backward compatibility
+
+The legacy script is retained only for existing engines that stop once at `brk #0x69` with the RX address in `x0` and its length in `x1`. It prepares that region, advances the program counter, and detaches. Do not build new integrations around `legacy.js`; implement the universal protocol instead.
+
+After Part 1 works with the matching script in StikDebug, continue with Part 2. The chosen script filename is developer-controlled and must not become a user setting.
+
+# Part 2: Integrate StikDebug and StikJIT into an existing iOS 26 JIT app
+
+Part 2 assumes all of the following already work:
+
+- The app can wait for an external debugger and detect when JIT is ready.
+- The app's JIT allocator implements its iOS 26 breakpoint and executable-region protocol.
+- JIT works when the developer or user manually selects the app and matching script in StikDebug.
+- The developer knows which script filename the app requires, normally `universal.js`.
+
+Part 2 preserves that existing **Wait for Debugger** path and adds automatic StikDebug URL launching and optional Built-in StikJIT. It does not change the app's JIT allocator or script protocol. If any prerequisite above is missing, complete Part 1 first.
+
+Complete the Built-in StikJIT subsections only if the app will embed the framework. A StikDebug-only integration can skip directly to **Configure the JIT methods**.
+
+## Built-in StikJIT: Embed the framework
+
+Built-in StikJIT uses two processes because a process that attaches a debugger to itself deadlocks:
+
+```text
+Host app (target PID, pairing-file settings, get-task-allow check)
+ ↕ XPC or another IPC mechanism
+Helper app extension (StikJIT, DDI cache, blocking work)
+```
+
+1. Add `StikJIT.xcframework` to the Xcode project.
+2. Link and embed it in the helper extension, not the host app.
+3. Set the helper extension deployment target to iOS 17.4 or later.
+4. Make sure the final app contains and signs the framework.
+5. Give the helper a way to receive the host PID, pairing-file data, settings, progress, and results. XPC is a good fit.
+
+If the host app supports older iOS versions, do not strongly link StikJIT into it. Keeping StikJIT in the iOS 17.4 helper prevents the app from trying to load the framework on an older system.
+
+Do not add IOKit to the host app's bridging header. The XCFramework links `IOKit.framework` and imports the required declarations internally.
+
+StikJIT does not provide or launch the helper extension. The app owns that integration.
+
+## Built-in StikJIT: Gate every entry point
+
+Before launching the helper, preparing the device, or enabling JIT, the host app should check:
+
+1. The device is running iOS 17.4 or later.
+2. The host app has `get-task-allow` set to `true`.
+3. A pairing file is available and readable.
+
+```swift
+guard #available(iOS 17.4, *) else {
+ showJITError("StikJIT requires iOS 17.4 or later.")
+ return
+}
+
+guard EntitlementChecker.hasGetTaskAllow else {
+ showJITError(
+ "This installation does not have get-task-allow. " +
+ "Reinstall the app using a signing method that preserves this entitlement."
+ )
+ return
+}
+
+guard let pairingData = try? Data(contentsOf: pairingFileURL) else {
+ showJITError("Import a valid pairing file before enabling JIT.")
+ return
+}
+
+launchHelper(targetPID: getpid(), pairingData: pairingData)
+```
+
+Perform these checks on both the manual **Prepare JIT** path and the automatic game or workload launch path. `get-task-allow` belongs to the host process identified by `targetPID`; checking from inside the helper checks the wrong executable.
+
+One host-side implementation is:
+
+```objc
+void* SecTaskCreateFromSelf(CFAllocatorRef allocator);
+CFTypeRef SecTaskCopyValueForEntitlement(
+ void* task,
+ CFStringRef entitlement,
+ CFErrorRef* error);
+
+static bool HasGetTaskAllow(void) {
+ void* task = SecTaskCreateFromSelf(NULL);
+ if (task == NULL) {
+ return false;
+ }
+
+ CFTypeRef value = SecTaskCopyValueForEntitlement(
+ task,
+ CFSTR("get-task-allow"),
+ NULL);
+ bool result = value == kCFBooleanTrue;
+
+ if (value != NULL) {
+ CFRelease(value);
+ }
+ CFRelease(task);
+ return result;
+}
+```
+
+Link `Security.framework` to the host target. This uses Security framework SPI on iOS and is not App Store-safe. StikJIT integrations already require a distribution environment that permits the helper and JIT behavior.
+
+## Built-in StikJIT: Store and import the pairing file
+
+The recommended host-app location is:
+
+```text
+Documents/StikJIT/pairingFile.plist
+```
+
+```swift
+let documents = FileManager.default.urls(
+ for: .documentDirectory,
+ in: .userDomainMask
+)[0]
+let stikJITDirectory = documents.appendingPathComponent("StikJIT")
+let pairingFileURL = stikJITDirectory.appendingPathComponent("pairingFile.plist")
+```
+
+Create the directory when needed. Let the user import a pairing file with `UIDocumentPickerViewController`, use security-scoped access while copying it, and replace the stored file atomically. Do not log its contents.
+
+Accept pairing files obtained through AFC/Finder, `idevice_pair`, or other pairing methods. To let users copy one into the app's Documents directory through AFC/Finder, add this to the host app's `Info.plist`:
+
+```xml
+UIFileSharingEnabled
+
+```
+
+You may also enable in-place access from the Files app:
+
+```xml
+LSSupportsOpeningDocumentsInPlace
+
+```
+
+Because pairing data crosses a process boundary, the host can read it into `Data`, send that over XPC, and let the helper write an extension-local temporary file. Delete the temporary file immediately after the operation. This avoids needing an App Group.
+
+## Built-in StikJIT: Keep the DDI cache in the helper
+
+The helper can use its persistent Library directory:
+
+```swift
+let library = FileManager.default.urls(
+ for: .libraryDirectory,
+ in: .userDomainMask
+)[0]
+let paths = DDIPaths.default(
+ in: library.appendingPathComponent("StikJIT")
+)
+```
+
+Use the same `DDIPaths` for preparation, JIT enablement, and cache reset. Do not validate cached DDI versions or contents yourself; StikJIT reuses readable, nonempty files and lets the image mounter decide whether they work.
+
+## Built-in StikJIT: Call the APIs at the right time
+
+StikJIT's preparation and enablement APIs are synchronous and blocking. Run them on one dedicated serial background queue in the helper. Do not split their ordered work into concurrent operations or convert it to `Task`/`async` calls.
+
+### When settings opens
+
+Use `StikJIT.isTXMPresent` to show `Present`, `Not Present`, or `Unknown`. This is informational; script selection still belongs to the developer.
+
+### When the user selects Prepare JIT
+
+After the host preflight, ask the helper to call:
+
+```swift
+let readiness = StikJIT.prepareDevice(
+ pairingFile: temporaryPairingFile,
+ paths: paths
+) { stage in
+ reportPreparationStage(stage)
+}
+```
+
+Display the result as:
+
+- `.ready` → **Ready**
+- `.unreachable(reason)` → **Unreachable**, with the reason
+- `.preparationFailed(reason)` → **Not Ready**, with the reason
+
+Preparation checks reachability, checks whether the DDI is mounted, downloads missing files, mounts, verifies the mount, and determines whether TXM is present. Those steps execute in that order.
+
+### When the user launches a game or workload
+
+Run the host preflight, launch the helper, and call `enableJIT`. Do not require the user to press **Prepare JIT** first; `enableJIT` performs preparation itself.
+
+```swift
+private let selectedScript: StikJIT.Script = .universal
+
+try StikJIT.enableJIT(
+ targetPID: hostPID,
+ pairingFile: temporaryPairingFile,
+ ddiPaths: paths,
+ script: selectedScript,
+ forceScript: forceScript,
+ preparationProgress: reportPreparationStage,
+ progress: reportJITLog
+)
+```
+
+Use `.universal` for new integrations. Use `.legacy` only when preserving an existing `legacy.js` ABI, or `.custom(URL)` for another established protocol. Keep that selection in backend code and use the corresponding filename in the StikDebug URL. Do not expose script selection to the user. The user-facing `forceScript` toggle only bypasses TXM detection and runs the developer-selected script regardless.
+
+### When the user resets the cache
+
+Provide a **Reset Developer Disk Image** action that calls:
+
+```swift
+try StikJIT.resetCachedDDI(at: paths)
+```
+
+This removes only the three cached DDI files. The next preparation downloads them again if mounting is necessary.
+
+## Configure the JIT methods
+
+Apps can expose one **JIT Method** setting with three mutually exclusive choices:
+
+- **Wait for Debugger** preserves the app's existing external-debugger flow.
+- **StikDebug** opens StikDebug and requests JIT for the current process.
+- **Built-in StikJIT** uses the app's helper extension and this framework.
+
+Route all three choices through the same host-side coordinator. Check `get-task-allow` first, return immediately if JIT is already ready, start only the selected method, and do not launch the game or workload until readiness has been established. Do not silently fall through to a different method after a failure.
+
+For **Wait for Debugger**, keep the app's existing waiting and readiness behavior. Where TXM/SPTM is present, it must continue through the existing breakpoint protocol before reporting success; an ordinary debugger attachment without the correct script is not sufficient.
+
+For **StikDebug**, construct the URL with `URLComponents` and include the bundle ID, current PID, and the developer-selected script filename:
+
+```swift
+private let stikDebugScriptName = "universal.js"
+
+guard let bundleID = Bundle.main.bundleIdentifier else {
+ showJITError("Could not determine the app's bundle ID.")
+ return
+}
+
+var components = URLComponents()
+components.scheme = "stikdebug"
+components.host = "enable-jit"
+components.queryItems = [
+ URLQueryItem(name: "bundle-id", value: bundleID),
+ URLQueryItem(name: "pid", value: String(getpid())),
+ URLQueryItem(name: "script-name", value: stikDebugScriptName),
+]
+
+guard let url = components.url else {
+ showJITError("Could not create the StikDebug request.")
+ return
+}
+
+UIApplication.shared.open(url)
+```
+
+Use `universal.js` for new integrations. Specify `legacy.js` only for an app that already implements the legacy ABI, or another installed script for an established custom protocol. Keep this choice in developer-controlled backend code and do not expose `script-name` as a user setting. StikDebug uses the PID to target the running process and the bundle ID to identify and return to the app.
+
+If the app checks whether StikDebug is installed with `canOpenURL`, add this to its `Info.plist`:
+
+```xml
+LSApplicationQueriesSchemes
+
+ stikdebug
+
+```
+
+Opening the URL confirms only that iOS accepted the request. Use the common readiness path after the app resumes; where TXM/SPTM is present, require the script breakpoint protocol to complete successfully.
+
+For **Built-in StikJIT**, follow the helper-extension flow in this guide. Keep `enableJIT` synchronous on the helper's serial queue while the host participates in the breakpoint protocol. If the app ships this complete integration, make it the recommended method; use StikDebug as the lightweight alternative and Wait for Debugger as the compatibility fallback.
+
+### LiveContainer
+
+Built-in StikJIT is unavailable when the host app is running inside LiveContainer because LiveContainer cannot create the required helper extension.
+
+Detect the environment in the host process:
+
+```swift
+import Darwin
+
+let isRunningInLiveContainer = getenv("LC_HOME_PATH") != nil
+```
+
+When this value is `true`:
+
+- Hide or disable Built-in StikJIT and explain why it is unavailable.
+- Do not launch the helper extension.
+- If Built-in StikJIT was previously selected, require the user to choose another method instead of silently attempting it.
+- Keep Wait for Debugger and StikDebug available.
+- For StikDebug, tell the user to enable **Use LiveContainer's Bundle ID** in LiveContainer settings.
+
+## Recommended settings
+
+At minimum, provide:
+
+- **JIT Method** picker with Wait for Debugger, StikDebug, and Built-in StikJIT
+- For Built-in StikJIT, **Import Pairing File** action and imported filename/status
+- For Built-in StikJIT, **Prepare JIT** action and readiness status/reason
+- For Built-in StikJIT, **TXM/SPTM** status: `Present`, `Not Present`, or `Unknown`
+- For Built-in StikJIT, **Force JIT Script** toggle, off by default
+- For Built-in StikJIT, **Reset Developer Disk Image** recovery action
+- For StikDebug, an installed/unavailable status and concise setup instructions
+- For Wait for Debugger, concise instructions explaining that the app will wait for an external tool
+
+Hide or disable Built-in StikJIT below iOS 17.4 and while running inside LiveContainer. Persist the selected method and the force-script toggle in the host app's preferences. Changing the pairing file should invalidate any displayed readiness state.
+
+## Minimum Built-in StikJIT user instructions
+
+Tell users all of the following before their first Built-in StikJIT attempt:
+
+1. StikJIT requires iOS 17.4 or later.
+2. The app must be installed with `get-task-allow`; otherwise it must be reinstalled using a compatible signing method.
+3. Developer Mode must be enabled.
+4. A pairing file obtained through AFC/Finder, `idevice_pair`, or another pairing method must be imported or placed at `StikJIT/pairingFile.plist` in the app's Documents folder.
+5. LocalDevVPN must be connected.
+6. Use a nearby Wi-Fi network when available. Without Wi-Fi, enable cellular data, connect LocalDevVPN, and then enable Airplane Mode before returning to the app.
+7. If preparation repeatedly fails, reboot the device and try again. Use **Reset Developer Disk Image** when the cached files need to be downloaded again.
+
+Always surface the actual unreachable, download, mount, pairing, and script errors. Do not reduce every failure to “DDI unmounted.”
+
+# Final checklist
+
+## All methods
+
+- [ ] New iOS 26 integrations implement the universal protocol; legacy is used only for backward compatibility.
+- [ ] The host checks its own `get-task-allow` before starting JIT acquisition.
+- [ ] The selected script matches the app's breakpoint protocol and is not user-configurable.
+- [ ] Where TXM/SPTM is present, executable-region preparation succeeds before the workload starts.
+- [ ] Settings offer one mutually exclusive JIT method selection and start only that method.
+
+### StikDebug
+
+- [ ] The URL includes bundle ID, current PID, and the developer-selected `script-name`.
+- [ ] `stikdebug` is in `LSApplicationQueriesSchemes` if the app calls `canOpenURL`.
+- [ ] The app does not treat successful URL opening as successful JIT acquisition.
+- [ ] Inside LiveContainer, the app tells users to enable **Use LiveContainer's Bundle ID**.
+
+### Built-in StikJIT
+
+- [ ] StikJIT is linked and embedded only where the helper can load it.
+- [ ] The helper deployment target is iOS 17.4 or later.
+- [ ] The host gates every Built-in StikJIT entry point to iOS 17.4 or later.
+- [ ] The host accepts pairing files obtained through AFC/Finder, `idevice_pair`, or other pairing methods and stores one at `Documents/StikJIT/pairingFile.plist`.
+- [ ] Pairing data is sent to the helper without logging it.
+- [ ] The helper uses one serial background queue.
+- [ ] The helper owns one persistent DDI cache location.
+- [ ] Game or workload launch calls `enableJIT` directly rather than requiring manual preparation first.
+- [ ] The host disables Built-in StikJIT and avoids launching the helper inside LiveContainer.
diff --git a/README.md b/README.md
index e9c8e67..b8a7da9 100644
--- a/README.md
+++ b/README.md
@@ -6,41 +6,67 @@ JIT cannot be enabled in-process, since a process that attaches a debugger to it
## Use
+For a complete host-app and helper-extension setup, see the [integration guide](INTEGRATION.md).
+
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:
+Add `StikJIT.xcframework` to the extension (Embed & Sign). StikJIT's preparation and JIT APIs are synchronous and blocking, so call them off the main thread.
-```swift
-import StikJIT
-
-try StikJIT.enableJIT(
- targetPID: hostPID, // process to enable JIT for
- pairingFile: pairingFileURL, // device pairing file
- progress: { print("[StikJIT] \($0)") }
-)
-```
-
-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`:
+Choose the JIT script in your integration code. By default StikJIT runs it when TXM is present and uses attach-only JIT when TXM is absent. `forceScript` bypasses TXM detection and runs the selected script regardless:
```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))%")
- }
+try StikJIT.enableJIT(
+ targetPID: hostPID,
+ pairingFile: pairingFileURL,
+ ddiPaths: paths,
+ script: .universal,
+ forceScript: forceJITScript,
+ progress: { print("[StikJIT] \($0)") }
+)
+```
+
+The developer-selected script can be `.universal`, `.legacy`, or `.custom(fileURL)`. A custom script must be readable from the extension process. Script selection should not be exposed as a user setting; a user-facing toggle maps only to `forceScript`.
+
+`enableJIT` prepares the device before attaching. It probes the tunnel endpoint, checks whether the DDI is mounted, downloads missing DDI files, mounts and verifies the DDI, determines whether TXM is present, and then enables JIT. These steps run serially in that order. Pass `configuration:` to override the tunnel endpoint and five-second connection timeout; the endpoint defaults to `10.7.0.1:49152`.
+
+### Developer Disk Image
+
+The device must have the personalized Developer Disk Image (DDI) mounted before JIT can be enabled. A mount persists until the device reboots, so it only needs to be redone once per boot. Apps can prepare it from settings before launching an extension:
+
+```swift
+import StikJIT
+
+let paths = DDIPaths.default(in: documentsDirectory)
+
+let readiness = StikJIT.prepareDevice(
+ pairingFile: pairingFileURL,
+ paths: paths
+) { stage in
+ print("[Preparation] \(stage)")
}
+
+switch readiness {
+case .ready(let securityState):
+ print("Ready; TXM present: \(String(describing: securityState.isTXMPresent))")
+case .unreachable(let reason):
+ print("Device unreachable: \(reason)")
+case .preparationFailed(let reason):
+ print("Preparation failed: \(reason)")
+}
+```
+
+TXM presence is also available without preparing the device through `StikJIT.isTXMPresent`. It returns `true` when TXM is present, `false` when it is absent, and `nil` when detection is unavailable.
+
+StikJIT treats readable, nonempty DDI files as cached and lets the device image mounter decide whether they are usable. A settings recovery action labeled **Reset Developer Disk Image** can remove the three cached files so the next preparation that needs to mount the DDI downloads them again:
+
+```swift
+try StikJIT.resetCachedDDI(at: paths)
```
[](https://deepwiki.com/StephenDev0/StikJIT)
diff --git a/Sources/BundledScript.swift b/Sources/BundledScript.swift
index 31910f9..3f08eeb 100644
--- a/Sources/BundledScript.swift
+++ b/Sources/BundledScript.swift
@@ -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
diff --git a/Sources/DeveloperDiskImageService.swift b/Sources/DeveloperDiskImageService.swift
index 8aa45ee..745b13f 100644
--- a/Sources/DeveloperDiskImageService.swift
+++ b/Sources/DeveloperDiskImageService.swift
@@ -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
diff --git a/Sources/EndpointProbe.swift b/Sources/EndpointProbe.swift
new file mode 100644
index 0000000..13f50b7
--- /dev/null
+++ b/Sources/EndpointProbe.swift
@@ -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
+ }
+}
diff --git a/Sources/JITSession.swift b/Sources/JITSession.swift
index 2da1edd..e845a80 100644
--- a/Sources/JITSession.swift
+++ b/Sources/JITSession.swift
@@ -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).")
diff --git a/Sources/ProcessInfo+TXM.swift b/Sources/ProcessInfo+TXM.swift
index 89de14d..ebf3dc7 100644
--- a/Sources/ProcessInfo+TXM.swift
+++ b/Sources/ProcessInfo+TXM.swift
@@ -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
}
}
diff --git a/Sources/ScriptRunner.swift b/Sources/ScriptRunner.swift
index 96302ce..e01b8e5 100644
--- a/Sources/ScriptRunner.swift
+++ b/Sources/ScriptRunner.swift
@@ -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() ?? "")")
+ 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?
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)
diff --git a/Sources/StikJIT.swift b/Sources/StikJIT.swift
index 4ad5f83..6b7b02a 100644
--- a/Sources/StikJIT.swift
+++ b/Sources/StikJIT.swift
@@ -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,
diff --git a/Sources/StikJITError.swift b/Sources/StikJITError.swift
index a0b30e9..63239d5 100644
--- a/Sources/StikJITError.swift
+++ b/Sources/StikJITError.swift
@@ -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)."
}
}
}
diff --git a/Sources/SynchronousDDIDownloader.swift b/Sources/SynchronousDDIDownloader.swift
new file mode 100644
index 0000000..126d431
--- /dev/null
+++ b/Sources/SynchronousDDIDownloader.swift
@@ -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?
+}
diff --git a/iokit/StikJITIOKit.h b/iokit/StikJITIOKit.h
new file mode 100644
index 0000000..8269358
--- /dev/null
+++ b/iokit/StikJITIOKit.h
@@ -0,0 +1 @@
+#include
diff --git a/iokit/module.modulemap b/iokit/module.modulemap
new file mode 100644
index 0000000..3a9ebb4
--- /dev/null
+++ b/iokit/module.modulemap
@@ -0,0 +1,4 @@
+module StikJITIOKit [system] {
+ header "StikJITIOKit.h"
+ export *
+}
diff --git a/project.yml b/project.yml
index f6cb69a..5439bb2 100644
--- a/project.yml
+++ b/project.yml
@@ -8,8 +8,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.0"
- MARKETING_VERSION: "1.0.0"
- CURRENT_PROJECT_VERSION: "1"
+ MARKETING_VERSION: "1.5.0"
+ CURRENT_PROJECT_VERSION: "6"
CLANG_CXX_LIBRARY: "libc++"
targets:
@@ -26,6 +26,7 @@ targets:
- sdk: Security.framework
- sdk: CFNetwork.framework
- sdk: SystemConfiguration.framework
+ - sdk: IOKit.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.stik.StikJIT
@@ -35,8 +36,8 @@ targets:
SKIP_INSTALL: NO
# idevice FFI: modulemap/headers to compile `import idevice`, and force_load the
# static lib so all its symbols are embedded in the framework binary (self-contained).
- SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice"
- HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice"
+ SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
+ HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
LIBRARY_SEARCH_PATHS: "$(SRCROOT)/idevice"
OTHER_LDFLAGS: "-force_load $(SRCROOT)/idevice/libidevice_ffi.a"