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

403
INTEGRATION.md Normal file
View file

@ -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
<key>UIFileSharingEnabled</key>
<true/>
```
You may also enable in-place access from the Files app:
```xml
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
```
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
<key>LSApplicationQueriesSchemes</key>
<array>
<string>stikdebug</string>
</array>
```
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.

View file

@ -6,43 +6,69 @@ JIT cannot be enabled in-process, since a process that attaches a debugger to it
## Use ## 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. 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. 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 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:
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`:
```swift ```swift
import StikJIT import StikJIT
let paths = DDIPaths.default(in: documentsDirectory) let paths = DDIPaths.default(in: documentsDirectory)
if try !StikJIT.isDDIMounted(pairingFile: pairingFileURL) { try StikJIT.enableJIT(
try await StikJIT.downloadDDIIfNeeded(to: paths) { fraction, status in targetPID: hostPID,
print("[DDI] \(status) (\(Int(fraction * 100))%)") pairingFile: pairingFileURL,
} ddiPaths: paths,
try StikJIT.mountDDI(pairingFile: pairingFileURL, paths: paths) { fraction in script: .universal,
print("[DDI] mounting \(Int(fraction * 100))%") 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)
```
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/StephenDev0/StikJIT) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/StephenDev0/StikJIT)
## Build ## Build

View file

@ -3,8 +3,25 @@ import Foundation
enum BundledScript { enum BundledScript {
static func source(for script: StikJIT.Script) throws -> String { 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) 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), let source = try? String(contentsOf: url, encoding: .utf8),
!source.isEmpty else { !source.isEmpty else {
throw StikJITError.scriptUnavailable throw StikJITError.scriptUnavailable

View file

@ -21,18 +21,55 @@ public struct DDIPaths: Sendable {
manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path) manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path)
} }
var allFilesExist: Bool { var allFilesUsable: Bool {
let fileManager = FileManager.default let fileManager = FileManager.default
return fileManager.fileExists(atPath: imagePath) return allPaths.allSatisfy { path in
&& fileManager.fileExists(atPath: trustcachePath) guard fileManager.isReadableFile(atPath: path),
&& fileManager.fileExists(atPath: manifestPath) 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, *) @available(iOS 17.4, *)
public actor DeveloperDiskImageService { public actor DeveloperDiskImageService {
public static let shared = DeveloperDiskImageService() private static let sharedInstance = DeveloperDiskImageService()
public static var shared: DeveloperDiskImageService { sharedInstance }
private let session: URLSession private let session: URLSession
@ -40,45 +77,29 @@ public actor DeveloperDiskImageService {
self.session = session 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 { 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) try await download(to: paths, progress: progress)
} }
public func download(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws { public func download(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
let items = [ let items = DDIDownloadCatalog.items(for: paths)
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) let total = Double(items.count)
for (index, item) in items.enumerated() { for (index, item) in items.enumerated() {
progress(Double(index) / total, "Downloading \(item.name)...") 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") progress(Double(index + 1) / total, "\(item.name) ready")
} }
} }
private func downloadFile(from urlString: String, to destinationURL: URL) async throws { private func downloadFile(from url: URL, 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) let (temporaryURL, response) = try await session.download(from: url)
guard let httpResponse = response as? HTTPURLResponse else { 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 { 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 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 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() let tunnel = try makeTunnel()
defer { tunnel.free() } defer { tunnel.free() }
@ -42,15 +46,18 @@ final class JITSession {
_ = try? sendCommand("QStartNoAckMode", over: debugProxy) _ = try? sendCommand("QStartNoAckMode", over: debugProxy)
debug_proxy_set_ack_mode(debugProxy, 0) 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() try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, script: script, progress: progress).run()
} else { case (false, .absent):
try attachWithoutScript(targetPID: targetPID, debugProxy: debugProxy, progress: progress) 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 { 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("vAttach;\(String(targetPID, radix: 16))", over: debugProxy)
_ = try? sendCommand("D", over: debugProxy) _ = try? sendCommand("D", over: debugProxy)
progress("JIT enabled (debugger attached and detached).") progress("JIT enabled (debugger attached and detached).")

View file

@ -1,58 +1,42 @@
import Foundation import Foundation
@_implementationOnly import StikJITIOKit
enum TXMPresence {
case present
case absent
case unknown
init(isPresent: Bool?) {
guard let isPresent else {
self = .unknown
return
}
self = isPresent ? .present : .absent
}
var isPresent: Bool? {
switch self {
case .present: return true
case .absent: return false
case .unknown: return nil
}
}
}
extension ProcessInfo { extension ProcessInfo {
var hasTXM: Bool { var txmPresence: TXMPresence {
let hardware = Self.hardwareIdentifier() let memoryMap = IORegistryEntryFromPath(kIOMainPortDefault, "IODeviceTree:/chosen/memory-map")
guard memoryMap != 0 else { return .unknown }
if Self.isIOS27OrNewer { let keysCF = IORegistryEntryCreateCFProperty(
return hardware != "iPad8,11" && hardware != "iPad8,12" memoryMap,
} kIORegistryEntryPropertyKeysKey as CFString,
kCFAllocatorDefault,
0
)?.takeRetainedValue()
IOObjectRelease(memoryMap)
guard let keys = keysCF as? [String] else { return .unknown }
if Self.isIOS26OrNewer { return keys.contains("TXM") ? .present : .absent
return Self.hasTXMSupport(hardwareIdentifier: hardware)
}
return false
}
private static func hasTXMSupport(hardwareIdentifier: String) -> Bool {
let firstTXM = 14.2
let iPadTXM = 14.5
guard let version = deviceVersion(from: hardwareIdentifier) else {
return false
}
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
} }
} }

View file

@ -13,6 +13,7 @@ final class ScriptRunner {
private let script: StikJIT.Script private let script: StikJIT.Script
private let progress: (String) -> Void private let progress: (String) -> Void
private var context: JSContext? private var context: JSContext?
private var executionError: StikJITError?
init(targetPID: Int32, debugProxy: OpaquePointer, script: StikJIT.Script, progress: @escaping (String) -> Void) { init(targetPID: Int32, debugProxy: OpaquePointer, script: StikJIT.Script, progress: @escaping (String) -> Void) {
self.targetPID = targetPID self.targetPID = targetPID
@ -28,7 +29,8 @@ final class ScriptRunner {
self.context = context self.context = context
context.exceptionHandler = { [weak self] _, value in 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) } 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 let prepare: @convention(block) (Double, Double) -> String = { [weak self] address, length in
guard let self else { return "" } 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 let log: @convention(block) (JSValue?) -> Void = { [weak self] value in
self?.progress(value?.toString() ?? "") self?.progress(value?.toString() ?? "")
@ -49,8 +51,9 @@ final class ScriptRunner {
context.setObject(prepare, forKeyedSubscript: "prepare_memory_region" as NSString) context.setObject(prepare, forKeyedSubscript: "prepare_memory_region" as NSString)
context.setObject(log, forKeyedSubscript: "log" 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) context.evaluateScript(source)
if let executionError { throw executionError }
progress("JIT script finished (region blessed, detached).") progress("JIT script finished (region blessed, detached).")
} }
@ -59,7 +62,7 @@ final class ScriptRunner {
defer { debugserver_command_free(handle) } defer { debugserver_command_free(handle) }
var response: UnsafeMutablePointer<CChar>? var response: UnsafeMutablePointer<CChar>?
if let error = debug_proxy_send_command(debugProxy, handle, &response) { 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 return nil
} }
guard let response else { return "" } guard let response else { return "" }
@ -83,7 +86,7 @@ final class ScriptRunner {
return debug_proxy_send_raw(debugProxy, base.advanced(by: byteOffset), UInt(byteCount)) return debug_proxy_send_raw(debugProxy, base.advanced(by: byteOffset), UInt(byteCount))
} }
if let sendError { 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 return nil
} }
@ -94,7 +97,7 @@ final class ScriptRunner {
idevice_string_free(response) idevice_string_free(response)
} }
if let readError { 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 return nil
} }
} }
@ -104,6 +107,13 @@ final class ScriptRunner {
return "OK" return "OK"
} }
private func recordExecutionError(_ error: StikJITError) {
if executionError == nil {
executionError = error
progress(error.localizedDescription)
}
}
private static func makeBlessCommands(startAddress: UInt64, pageCount: Int) -> [UInt8] { private static func makeBlessCommands(startAddress: UInt64, pageCount: Int) -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: pageCount * jitPageCommandLength) var buffer = [UInt8](repeating: 0, count: pageCount * jitPageCommandLength)

View file

@ -8,35 +8,163 @@ public enum StikJIT {
case legacy case legacy
var resourceName: String { case custom(URL)
var name: String {
switch self { switch self {
case .universal: return "universal" case .universal: return "universal"
case .legacy: return "legacy" 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 struct Configuration: Sendable {
public var deviceAddress: String public var deviceAddress: String
public var rsdPort: UInt16 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.deviceAddress = deviceAddress
self.rsdPort = rsdPort 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, public static func enableJIT(targetPID: Int32,
pairingFile: URL, pairingFile: URL,
configuration: Configuration = .default, configuration: Configuration = .default,
script: Script = .universal, script: Script = .universal,
forceScript: Bool = false,
progress: @escaping (String) -> Void = { _ in }) throws { progress: @escaping (String) -> Void = { _ in }) throws {
let txmPresence = ProcessInfo.processInfo.txmPresence
let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration) 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, public static func isDDIMounted(pairingFile: URL,

View file

@ -14,6 +14,16 @@ public enum StikJITError: Error, LocalizedError {
case ddiFilesMissing(String) case ddiFilesMissing(String)
case ddiNotMounted
case deviceNotReady(String)
case txmDetectionUnavailable
case customScript(String)
case scriptExecution(String)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .pairingFile(let detail): case .pairingFile(let detail):
@ -28,6 +38,16 @@ public enum StikJITError: Error, LocalizedError {
return "Failed to download Developer Disk Image: \(detail)." return "Failed to download Developer Disk Image: \(detail)."
case .ddiFilesMissing(let detail): case .ddiFilesMissing(let detail):
return "Developer Disk Image files missing: \(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?
}

1
iokit/StikJITIOKit.h Normal file
View file

@ -0,0 +1 @@
#include <IOKit/IOKitLib.h>

4
iokit/module.modulemap Normal file
View file

@ -0,0 +1,4 @@
module StikJITIOKit [system] {
header "StikJITIOKit.h"
export *
}

View file

@ -8,8 +8,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.5.0"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "6"
CLANG_CXX_LIBRARY: "libc++" CLANG_CXX_LIBRARY: "libc++"
targets: targets:
@ -26,6 +26,7 @@ targets:
- sdk: Security.framework - sdk: Security.framework
- sdk: CFNetwork.framework - sdk: CFNetwork.framework
- sdk: SystemConfiguration.framework - sdk: SystemConfiguration.framework
- sdk: IOKit.framework
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.stik.StikJIT PRODUCT_BUNDLE_IDENTIFIER: com.stik.StikJIT
@ -35,8 +36,8 @@ targets:
SKIP_INSTALL: NO SKIP_INSTALL: NO
# idevice FFI: modulemap/headers to compile `import idevice`, and force_load the # 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). # static lib so all its symbols are embedded in the framework binary (self-contained).
SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice" SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice" HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
LIBRARY_SEARCH_PATHS: "$(SRCROOT)/idevice" LIBRARY_SEARCH_PATHS: "$(SRCROOT)/idevice"
OTHER_LDFLAGS: "-force_load $(SRCROOT)/idevice/libidevice_ffi.a" OTHER_LDFLAGS: "-force_load $(SRCROOT)/idevice/libidevice_ffi.a"