mirror of
https://github.com/StikDebug/StikJIT.git
synced 2026-10-16 12:30:30 +00:00
Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32287268fa | ||
|
|
5d732f94b8 | ||
|
|
ee457f2f40 | ||
|
|
ef4e105cd7 | ||
|
|
4ba4072748 | ||
|
|
e6bbfe0399 | ||
|
|
4e08cecab9 | ||
|
|
709898d500 | ||
|
|
9ba3f85890 | ||
|
|
3623e72587 | ||
|
|
640fac91de | ||
|
|
dce417d893 | ||
|
|
a9390e36db | ||
|
|
1230fc835b | ||
|
|
95b8e4d3f7 | ||
| ee3d5836aa | |||
|
|
2320badda8 | ||
|
|
5bdecfcafa | ||
| 41498683f3 | |||
| 38762b8306 |
19 changed files with 5135 additions and 135 deletions
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
|
|
@ -14,11 +14,11 @@ permissions:
|
|||
jobs:
|
||||
build:
|
||||
name: Build XCFramework
|
||||
runs-on: macos-15
|
||||
runs-on: xcode-27
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show toolchain
|
||||
run: xcodebuild -version
|
||||
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
run: zip -ry StikJIT.xcframework.zip StikJIT.xcframework
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: StikJIT.xcframework
|
||||
path: StikJIT.xcframework.zip
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
|
||||
- name: Create release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: StikJIT.xcframework.zip
|
||||
generate_release_notes: true
|
||||
|
|
|
|||
443
INTEGRATION.md
Normal file
443
INTEGRATION.md
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
# 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 whether it uses `universal.js`, `legacy.js`, or a custom script file, then 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.
|
||||
|
||||
> **Note:** We keep legacy script support only for backward compatibility with existing applications. If you are adding JIT support to an app now, use the universal script.
|
||||
|
||||
After Part 1 works with the matching script in StikDebug, continue with Part 2. The chosen script 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 whether the app requires `universal.js`, `legacy.js`, or a custom script file.
|
||||
|
||||
If the app currently uses `legacy.js`, we recommend switching it to the universal method, but this is not required to complete Part 2.
|
||||
|
||||
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. Before iOS 26.4, StikJIT downloads and mounts the three-file personalized DDI. On iOS 26.4 and newer, it downloads and installs the five-file cryptex DDI, which additionally includes cryptex info and a root hash. Do not validate cached DDI versions or contents yourself; StikJIT selects and replaces the cache format required by the running OS.
|
||||
|
||||
A successful DDI mount persists until the device reboots, so it normally needs to be mounted only once per boot.
|
||||
|
||||
## 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.
|
||||
|
||||
Pass `configuration:` when the integration needs to override the tunnel endpoint or five-second connection timeout. The default endpoint is `10.7.0.1:49152`.
|
||||
|
||||
### When settings opens
|
||||
|
||||
Use `StikJIT.isTXMPresent` to show `Present`, `Not Present`, or `Unknown`. This is informational; the app's single script remains developer-defined.
|
||||
|
||||
### 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
|
||||
)
|
||||
```
|
||||
|
||||
Configure exactly one script in backend code: `.universal` or `.legacy` for the corresponding bundled script, `.custom(URL)` for a script readable from the helper-extension process, or `.customBase64(String)` for base64-encoded UTF-8 script data held elsewhere. For StikDebug requests, send the built-in filename for universal or legacy, or send the base64-encoded contents of the custom script. Do not expose this configuration to the user. The user-facing `forceScript` toggle only bypasses TXM detection and runs the configured script regardless.
|
||||
|
||||
```swift
|
||||
let selectedScript: StikJIT.Script = .customBase64(storedScriptBase64)
|
||||
```
|
||||
|
||||
### 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 five possible cached DDI files. The next preparation downloads the three-file personalized DDI or five-file cryptex DDI required by the running OS 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**, always construct the URL with the bundle ID and current PID. If TXM/SPTM is present, also send the app's JIT script. Each app must use exactly one developer-defined script; never add runtime script selection or expose it as a user setting. When TXM/SPTM is not present, omit the script parameter because debugger attachment alone enables JIT.
|
||||
|
||||
This example is for an app that uses the recommended universal script:
|
||||
|
||||
```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())),
|
||||
]
|
||||
|
||||
if isTXMPresent {
|
||||
components.queryItems?.append(
|
||||
URLQueryItem(name: "script-name", value: stikDebugScriptName)
|
||||
)
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
showJITError("Could not create the StikDebug request.")
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url)
|
||||
```
|
||||
|
||||
An existing legacy app can use the same code with its fixed script name set to `legacy.js`. A custom-script app must remove `stikDebugScriptName` and replace the `if isTXMPresent` block with this fixed custom-script implementation:
|
||||
|
||||
```swift
|
||||
if isTXMPresent {
|
||||
do {
|
||||
let scriptData = try Data(contentsOf: stikDebugScriptURL)
|
||||
components.queryItems?.append(
|
||||
URLQueryItem(
|
||||
name: "script-data",
|
||||
value: scriptData.base64EncodedString()
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
showJITError("Could not read the StikDebug script: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`stikDebugScriptURL` must identify the app's single custom script in developer-controlled backend code. Sending it through `script-data` means users do not need to install it in StikDebug. Resolve TXM/SPTM presence before building the request rather than treating an unknown result as absent. 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.”
|
||||
|
||||
## Request endorsement
|
||||
|
||||
After completing Part 1, Part 2, or both, please get in touch with us in the [idevice Discord server](https://discord.gg/qtgv6QtYbV) or leave a GitHub issue/pull request, so we can endorse your project and offer support to your users. We review app submissions on a case-by-case basis and generally decline projects without an established user base, low-quality substantially AI-generated projects, and forks of apps that already have our endorsement.
|
||||
|
||||
# Final checklist
|
||||
|
||||
## All methods
|
||||
|
||||
- [ ] New iOS 26 integrations implement the universal protocol.
|
||||
- [ ] The host checks its own `get-task-allow` before starting JIT acquisition.
|
||||
- [ ] The app has exactly one developer-configured script that matches its breakpoint protocol.
|
||||
- [ ] 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 always includes bundle ID and current PID.
|
||||
- [ ] Where TXM/SPTM is present, the URL includes `script-name` for universal or legacy, or base64-encoded `script-data` for a custom script.
|
||||
- [ ] Where TXM/SPTM is not present, the URL omits both script parameters.
|
||||
- [ ] `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.
|
||||
22
README.md
22
README.md
|
|
@ -1,24 +1,14 @@
|
|||
# StikJIT
|
||||
|
||||
An iOS XCFramework that enables JIT for another process over the device's RSD tunnel. Based on StikDebug.
|
||||
An iOS XCFramework that enables JIT for another process over the device's RSD tunnel.
|
||||
|
||||
A separate process (e.g. an app extension) attaches a debugserver to the target by PID, marks its JIT memory executable, then detaches. It is self-contained: the idevice FFI and `universal.js` are bundled inside. The target must carry the `get-task-allow` entitlement.
|
||||
StikJIT is self-contained and bundles the idevice FFI and its JIT scripts.
|
||||
|
||||
## Use
|
||||
## Integration
|
||||
|
||||
Add `StikJIT.xcframework` to your target (Embed & Sign), then call it off the main thread:
|
||||
For iOS 26 JIT support, StikDebug URL integration, Built-in StikJIT setup, API usage, requirements, and recommended app settings, see [Integrating StikJIT](INTEGRATION.md).
|
||||
|
||||
```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`).
|
||||
[](https://deepwiki.com/StephenDev0/StikJIT)
|
||||
|
||||
## Build
|
||||
|
||||
|
|
@ -33,4 +23,4 @@ xcodebuild -create-xcframework \
|
|||
|
||||
## License
|
||||
|
||||
StikJIT is licensed under the MPL-2.0 (see [`LICENSE`](LICENSE)). It uses StikDebug as a reference, with the bundled [idevice](https://github.com/jkcoxson/idevice) and universal.js retaining their own licenses.
|
||||
StikJIT is licensed under the MPL-2.0 (see [`LICENSE`](LICENSE)). It uses StikDebug as a reference, with the bundled [idevice](https://github.com/jkcoxson/idevice), universal.js, and legacy.js retaining their own licenses.
|
||||
|
|
|
|||
98
Resources/legacy.js
Normal file
98
Resources/legacy.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
function littleEndianHexStringToNumber(hexStr) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < hexStr.length; i += 2) {
|
||||
bytes.push(parseInt(hexStr.substr(i, 2), 16));
|
||||
}
|
||||
let num = 0n;
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
num = (num << 8n) | BigInt(bytes[i]);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function numberToLittleEndianHexString(num) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
bytes.push(Number(num & 0xFFn));
|
||||
num >>= 8n;
|
||||
}
|
||||
while (bytes.length < 8) {
|
||||
bytes.push(0);
|
||||
}
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function littleEndianHexToU32(hexStr) {
|
||||
return parseInt(hexStr.match(/../g).reverse().join(''), 16);
|
||||
}
|
||||
|
||||
function extractBrkImmediate(u32) {
|
||||
return (u32 >> 5) & 0xFFFF;
|
||||
}
|
||||
|
||||
function attach(breakpointcount) {
|
||||
let pid = get_pid();
|
||||
log(`pid = ${pid}`);
|
||||
let attachResponse = send_command(`vAttach;${pid.toString(16)}`);
|
||||
log(`attach_response = ${attachResponse}`);
|
||||
|
||||
let validBreakpoints = 0;
|
||||
let totalBreakpoints = 0;
|
||||
|
||||
while (validBreakpoints < breakpointcount) {
|
||||
totalBreakpoints++;
|
||||
log(`Handling breakpoint ${totalBreakpoints} (looking for valid breakpoint ${validBreakpoints + 1}/${breakpointcount})`);
|
||||
|
||||
let brkResponse = send_command(`c`);
|
||||
log(`brkResponse = ${brkResponse}`);
|
||||
|
||||
let tidMatch = /T[0-9a-f]+thread:(?<tid>[0-9a-f]+);/.exec(brkResponse);
|
||||
let tid = tidMatch ? tidMatch.groups['tid'] : null;
|
||||
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let pc = pcMatch ? pcMatch.groups['reg'] : null;
|
||||
let x0Match = /00:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x0 = x0Match ? x0Match.groups['reg'] : null;
|
||||
let x1Match = /01:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
|
||||
let x1 = x1Match ? x1Match.groups['reg'] : null;
|
||||
|
||||
if (!tid || !pc || !x0 || !x1) {
|
||||
log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}, x1=${x1}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pcNum = littleEndianHexStringToNumber(pc);
|
||||
const x0Num = littleEndianHexStringToNumber(x0);
|
||||
const x1Num = littleEndianHexStringToNumber(x1);
|
||||
log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}, x1 = ${x1Num.toString(16)}`);
|
||||
|
||||
let instructionResponse = send_command(`m${pcNum.toString(16)},4`);
|
||||
log(`instruction at pc: ${instructionResponse}`);
|
||||
let instrU32 = littleEndianHexToU32(instructionResponse);
|
||||
let brkImmediate = extractBrkImmediate(instrU32);
|
||||
log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`);
|
||||
|
||||
if (brkImmediate !== 0x69) {
|
||||
log(`Skipping breakpoint: brk immediate was not 0x69 (was 0x${brkImmediate.toString(16)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
log(`BRK immediate matches expected value 0x69 - processing valid breakpoint ${validBreakpoints + 1}/${breakpointcount}`);
|
||||
|
||||
log(`Allocated JIT page at address: 0x${x0Num.toString(16)}`);
|
||||
|
||||
let prepareJITPageResponse = prepare_memory_region(x0Num, x1Num);
|
||||
log(`prepareJITPageResponse = ${prepareJITPageResponse}`);
|
||||
|
||||
let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
|
||||
let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`);
|
||||
log(`pcPlus4Response = ${pcPlus4Response}`);
|
||||
|
||||
validBreakpoints++;
|
||||
log(`Completed valid breakpoint ${validBreakpoints}/${breakpointcount}`);
|
||||
}
|
||||
|
||||
let detachResponse = send_command(`D`);
|
||||
log(`detachResponse = ${detachResponse}`);
|
||||
}
|
||||
|
||||
attach(1);
|
||||
|
|
@ -2,9 +2,40 @@ import Foundation
|
|||
|
||||
enum BundledScript {
|
||||
|
||||
static func universalJS() throws -> String {
|
||||
static func source(for script: StikJIT.Script) throws -> String {
|
||||
switch script {
|
||||
case .custom(let url):
|
||||
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)")
|
||||
}
|
||||
case .customBase64(let encodedSource):
|
||||
guard let data = Data(base64Encoded: encodedSource) else {
|
||||
throw StikJITError.customScript("the provided script data is not valid base64")
|
||||
}
|
||||
guard let source = String(data: data, encoding: .utf8) else {
|
||||
throw StikJITError.customScript("the decoded script data is not valid UTF-8")
|
||||
}
|
||||
guard !source.isEmpty else {
|
||||
throw StikJITError.customScript("the decoded script is empty")
|
||||
}
|
||||
return source
|
||||
case .universal, .legacy:
|
||||
break
|
||||
}
|
||||
|
||||
let bundle = Bundle(for: BundleToken.self)
|
||||
guard let url = bundle.url(forResource: "universal", 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
|
||||
|
|
|
|||
228
Sources/DDISession.swift
Normal file
228
Sources/DDISession.swift
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import Foundation
|
||||
@_implementationOnly import idevice
|
||||
|
||||
private func ddiMountProgressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
|
||||
guard let context else { return }
|
||||
let progressHandler = Unmanaged<DDIProgressBox>.fromOpaque(context).takeUnretainedValue()
|
||||
let fraction = total > 0 ? Double(progress) / Double(total) : 0
|
||||
progressHandler.handler(fraction)
|
||||
}
|
||||
|
||||
private final class DDIProgressBox {
|
||||
let handler: (Double) -> Void
|
||||
init(_ handler: @escaping (Double) -> Void) { self.handler = handler }
|
||||
}
|
||||
|
||||
final class DDISession {
|
||||
|
||||
private struct Tunnel {
|
||||
var adapter: OpaquePointer?
|
||||
var handshake: OpaquePointer?
|
||||
func free() {
|
||||
if let handshake { rsd_handshake_free(handshake) }
|
||||
if let adapter { adapter_free(adapter) }
|
||||
}
|
||||
}
|
||||
|
||||
private let pairingFilePath: String
|
||||
private let configuration: StikJIT.Configuration
|
||||
|
||||
init(pairingFilePath: String, configuration: StikJIT.Configuration) {
|
||||
self.pairingFilePath = pairingFilePath
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func isMounted() throws -> Bool {
|
||||
let tunnel = try makeTunnel()
|
||||
defer { tunnel.free() }
|
||||
|
||||
if DDIMountMethod.current == .personalized {
|
||||
return try isLegacyDDIMounted(over: tunnel)
|
||||
}
|
||||
|
||||
var installed: UnsafeMutablePointer<InstalledCryptexC>?
|
||||
let cryptexError = cryptexd_installed_ddi(tunnel.adapter, tunnel.handshake, &installed)
|
||||
defer { cryptexd_free_installed_cryptex(installed) }
|
||||
if installed != nil {
|
||||
if let cryptexError { idevice_error_free(cryptexError) }
|
||||
return true
|
||||
}
|
||||
|
||||
if (try? isLegacyDDIMounted(over: tunnel)) == true {
|
||||
if let cryptexError { idevice_error_free(cryptexError) }
|
||||
return true
|
||||
}
|
||||
if let cryptexError {
|
||||
try IdeviceFFI.check("failed to query installed DDI cryptex") { cryptexError }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isLegacyDDIMounted(over tunnel: Tunnel) throws -> Bool {
|
||||
var client: OpaquePointer?
|
||||
try IdeviceFFI.check("failed to connect to image mounter") {
|
||||
image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
|
||||
}
|
||||
guard let client else { throw StikJITError.debugProxyUnavailable }
|
||||
defer { image_mounter_free(client) }
|
||||
|
||||
var devices: UnsafeMutablePointer<plist_t?>?
|
||||
var deviceCount = 0
|
||||
try IdeviceFFI.check("failed to fetch mounted devices") {
|
||||
image_mounter_copy_devices(client, &devices, &deviceCount)
|
||||
}
|
||||
if let devices {
|
||||
for index in 0..<deviceCount { plist_free(devices[index]) }
|
||||
idevice_data_free(
|
||||
UnsafeMutableRawPointer(devices).assumingMemoryBound(to: UInt8.self),
|
||||
UInt(deviceCount * MemoryLayout<plist_t?>.stride))
|
||||
}
|
||||
return deviceCount > 0
|
||||
}
|
||||
|
||||
func mountDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws {
|
||||
switch DDIMountMethod.current {
|
||||
case .cryptex:
|
||||
try installCryptexDDI(from: URL(fileURLWithPath: paths.manifestPath).deletingLastPathComponent().path, progress: progress)
|
||||
case .personalized:
|
||||
try mountPersonalizedDDI(paths: paths, progress: progress)
|
||||
}
|
||||
}
|
||||
|
||||
private func installCryptexDDI(from directoryPath: String, progress: @escaping (Double) -> Void) throws {
|
||||
progress(0)
|
||||
var assets: OpaquePointer?
|
||||
try IdeviceFFI.check("failed to load DDI cryptex assets") {
|
||||
directoryPath.withCString { cryptex1_assets_load($0, &assets) }
|
||||
}
|
||||
guard let assets else {
|
||||
throw StikJITError.ddiFilesMissing("DDI cryptex assets could not be loaded from \(directoryPath)")
|
||||
}
|
||||
defer { cryptex1_assets_free(assets) }
|
||||
|
||||
let tunnel = try makeTunnel()
|
||||
defer { tunnel.free() }
|
||||
|
||||
try IdeviceFFI.check("failed to install DDI cryptex") {
|
||||
cryptexd_install_ddi(tunnel.adapter, tunnel.handshake, assets, nil)
|
||||
}
|
||||
progress(1)
|
||||
}
|
||||
|
||||
private func mountPersonalizedDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws {
|
||||
let imageData = try mappedFileData(atPath: paths.imagePath, description: "developer disk image")
|
||||
let trustcacheData = try mappedFileData(atPath: paths.trustcachePath, description: "developer disk image trust cache")
|
||||
let manifestData = try mappedFileData(atPath: paths.manifestPath, description: "developer disk image manifest")
|
||||
|
||||
let tunnel = try makeTunnel()
|
||||
defer { tunnel.free() }
|
||||
|
||||
let uniqueChipID = try fetchUniqueChipID(over: tunnel)
|
||||
|
||||
var client: OpaquePointer?
|
||||
try IdeviceFFI.check("failed to connect to image mounter") {
|
||||
image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
|
||||
}
|
||||
guard let client else { throw StikJITError.debugProxyUnavailable }
|
||||
defer { image_mounter_free(client) }
|
||||
|
||||
let progressBox = DDIProgressBox(progress)
|
||||
let context = Unmanaged.passUnretained(progressBox).toOpaque()
|
||||
|
||||
try IdeviceFFI.check("failed to mount personalized developer disk image") {
|
||||
imageData.withUnsafeBytes { imageBuffer in
|
||||
trustcacheData.withUnsafeBytes { trustcacheBuffer in
|
||||
manifestData.withUnsafeBytes { manifestBuffer in
|
||||
image_mounter_mount_personalized_with_callback_rsd(
|
||||
client,
|
||||
tunnel.adapter,
|
||||
tunnel.handshake,
|
||||
imageBuffer.bindMemory(to: UInt8.self).baseAddress,
|
||||
imageData.count,
|
||||
trustcacheBuffer.bindMemory(to: UInt8.self).baseAddress,
|
||||
trustcacheData.count,
|
||||
manifestBuffer.bindMemory(to: UInt8.self).baseAddress,
|
||||
manifestData.count,
|
||||
nil,
|
||||
uniqueChipID,
|
||||
ddiMountProgressCallback,
|
||||
context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mappedFileData(atPath path: String, description: String) throws -> Data {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
throw StikJITError.ddiFilesMissing("\(description) not found at \(path)")
|
||||
}
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
|
||||
guard !data.isEmpty else {
|
||||
throw StikJITError.ddiFilesMissing("\(description) is empty at \(path)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func fetchUniqueChipID(over tunnel: Tunnel) throws -> UInt64 {
|
||||
var client: OpaquePointer?
|
||||
try IdeviceFFI.check("failed to connect to lockdownd") {
|
||||
lockdownd_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
|
||||
}
|
||||
guard let client else { throw StikJITError.debugProxyUnavailable }
|
||||
defer { lockdownd_client_free(client) }
|
||||
|
||||
var uniqueChipIDPlist: plist_t?
|
||||
try IdeviceFFI.check("failed to query UniqueChipID") {
|
||||
"UniqueChipID".withCString { lockdownd_get_value(client, $0, nil, &uniqueChipIDPlist) }
|
||||
}
|
||||
guard let uniqueChipIDPlist else {
|
||||
throw StikJITError.device(code: -1, subCode: 0, message: "UniqueChipID was not returned by lockdownd")
|
||||
}
|
||||
defer { plist_free(uniqueChipIDPlist) }
|
||||
|
||||
var value: UInt64 = 0
|
||||
plist_get_uint_val(uniqueChipIDPlist, &value)
|
||||
guard value != 0 else {
|
||||
throw StikJITError.device(code: -1, subCode: 0, message: "Failed to decode UniqueChipID")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func openPairingFile() throws -> OpaquePointer {
|
||||
guard !pairingFilePath.isEmpty, FileManager.default.fileExists(atPath: pairingFilePath) else {
|
||||
throw StikJITError.pairingFile("not found at \(pairingFilePath)")
|
||||
}
|
||||
var handle: OpaquePointer?
|
||||
try IdeviceFFI.check("failed to read pairing file") {
|
||||
pairingFilePath.withCString { rp_pairing_file_read($0, &handle) }
|
||||
}
|
||||
guard let handle else { throw StikJITError.pairingFile("unreadable at \(pairingFilePath)") }
|
||||
return handle
|
||||
}
|
||||
|
||||
private func makeTunnel() throws -> Tunnel {
|
||||
let pairing = try openPairingFile()
|
||||
defer { rp_pairing_file_free(pairing) }
|
||||
|
||||
var address = sockaddr_in()
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_port = configuration.rsdPort.bigEndian
|
||||
_ = configuration.deviceAddress.withCString { inet_pton(AF_INET, $0, &address.sin_addr) }
|
||||
|
||||
var tunnel = Tunnel()
|
||||
try IdeviceFFI.check("failed to create RSD tunnel") {
|
||||
"StikJIT".withCString { hostname in
|
||||
withUnsafePointer(to: &address) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
tunnel_create_rppairing(
|
||||
sa, socklen_t(MemoryLayout<sockaddr_in>.stride),
|
||||
hostname, pairing, nil, nil,
|
||||
&tunnel.adapter, &tunnel.handshake)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tunnel
|
||||
}
|
||||
}
|
||||
162
Sources/DeveloperDiskImageService.swift
Normal file
162
Sources/DeveloperDiskImageService.swift
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import Foundation
|
||||
|
||||
public struct DDIPaths: Sendable {
|
||||
|
||||
public var imagePath: String
|
||||
|
||||
public var trustcachePath: String
|
||||
|
||||
public var manifestPath: String
|
||||
|
||||
public var cryptexInfoPath: String
|
||||
|
||||
public var rootHashPath: String
|
||||
|
||||
public init(imagePath: String,
|
||||
trustcachePath: String,
|
||||
manifestPath: String,
|
||||
cryptexInfoPath: String? = nil,
|
||||
rootHashPath: String? = nil) {
|
||||
self.imagePath = imagePath
|
||||
self.trustcachePath = trustcachePath
|
||||
self.manifestPath = manifestPath
|
||||
self.cryptexInfoPath = cryptexInfoPath ?? "\(imagePath).cryptex_info"
|
||||
self.rootHashPath = rootHashPath ?? "\(imagePath).root_hash"
|
||||
}
|
||||
|
||||
public static func `default`(in directory: URL) -> DDIPaths {
|
||||
DDIPaths(
|
||||
imagePath: directory.appendingPathComponent("DDI/Image.dmg").path,
|
||||
trustcachePath: directory.appendingPathComponent("DDI/Image.dmg.trustcache").path,
|
||||
manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path)
|
||||
}
|
||||
|
||||
var allFilesUsable: Bool {
|
||||
let fileManager = FileManager.default
|
||||
let requiredPaths = DDIMountMethod.current == .cryptex ? allPaths : personalizedPaths
|
||||
let requiredFilesUsable = requiredPaths.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
|
||||
}
|
||||
if DDIMountMethod.current == .personalized {
|
||||
return requiredFilesUsable && cryptexOnlyPaths.allSatisfy { !fileManager.fileExists(atPath: $0) }
|
||||
}
|
||||
return requiredFilesUsable
|
||||
}
|
||||
|
||||
var allPaths: [String] {
|
||||
personalizedPaths + cryptexOnlyPaths
|
||||
}
|
||||
|
||||
var personalizedPaths: [String] {
|
||||
[imagePath, trustcachePath, manifestPath]
|
||||
}
|
||||
|
||||
var cryptexOnlyPaths: [String] {
|
||||
[cryptexInfoPath, rootHashPath]
|
||||
}
|
||||
|
||||
func removeCachedFiles() throws {
|
||||
let fileManager = FileManager.default
|
||||
for path in Set(allPaths) where fileManager.fileExists(atPath: path) {
|
||||
try fileManager.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCryptexOnlyFiles() throws {
|
||||
let fileManager = FileManager.default
|
||||
for path in Set(cryptexOnlyPaths) where fileManager.fileExists(atPath: path) {
|
||||
try fileManager.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DDIMountMethod: String {
|
||||
case cryptex
|
||||
case personalized
|
||||
|
||||
static var current: DDIMountMethod {
|
||||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return version.majorVersion > 26 || (version.majorVersion == 26 && version.minorVersion >= 4)
|
||||
? .cryptex
|
||||
: .personalized
|
||||
}
|
||||
}
|
||||
|
||||
struct DDIDownloadItem {
|
||||
let name: String
|
||||
let destinationPath: String
|
||||
let url: URL
|
||||
}
|
||||
|
||||
enum DDIDownloadCatalog {
|
||||
static func items(for paths: DDIPaths) -> [DDIDownloadItem] {
|
||||
let directory = DDIMountMethod.current == .cryptex ? "Xcode_iOS_DDI_Cryptex" : "Xcode_iOS_DDI_Personalized"
|
||||
let baseURL = URL(string: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/\(directory)")!
|
||||
var items = [
|
||||
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")),
|
||||
]
|
||||
if DDIMountMethod.current == .cryptex {
|
||||
items.append(DDIDownloadItem(name: "Image.dmg.cryptex_info", destinationPath: paths.cryptexInfoPath, url: baseURL.appendingPathComponent("Image.dmg.cryptex_info")))
|
||||
items.append(DDIDownloadItem(name: "Image.dmg.root_hash", destinationPath: paths.rootHashPath, url: baseURL.appendingPathComponent("Image.dmg.root_hash")))
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.4, *)
|
||||
public actor DeveloperDiskImageService {
|
||||
|
||||
private static let sharedInstance = DeveloperDiskImageService()
|
||||
|
||||
public static var shared: DeveloperDiskImageService { sharedInstance }
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func downloadIfNeeded(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
|
||||
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 = 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.url, to: URL(fileURLWithPath: item.destinationPath))
|
||||
progress(Double(index + 1) / total, "\(item.name) ready")
|
||||
}
|
||||
if DDIMountMethod.current == .personalized {
|
||||
try paths.removeCryptexOnlyFiles()
|
||||
}
|
||||
}
|
||||
|
||||
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 \(url.absoluteString)")
|
||||
}
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(url.absoluteString)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
56
Sources/EndpointProbe.swift
Normal file
56
Sources/EndpointProbe.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,11 @@ final class JITSession {
|
|||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func enableJIT(targetPID: Int32, 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 {
|
||||
try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, progress: progress).run()
|
||||
} else {
|
||||
switch (forceScript, txmPresence) {
|
||||
case (true, _), (false, .present):
|
||||
try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, script: script, progress: progress).run()
|
||||
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).")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,26 +5,32 @@ import JavaScriptCore
|
|||
final class ScriptRunner {
|
||||
|
||||
private static let jitPageSize: UInt64 = 16384
|
||||
private static let jitPageCommandLength = 19
|
||||
private static let commandsPerBatch = 128
|
||||
|
||||
private let targetPID: Int32
|
||||
private let debugProxy: OpaquePointer
|
||||
private let script: StikJIT.Script
|
||||
private let progress: (String) -> Void
|
||||
private var context: JSContext?
|
||||
private var executionError: StikJITError?
|
||||
|
||||
init(targetPID: Int32, debugProxy: OpaquePointer, progress: @escaping (String) -> Void) {
|
||||
init(targetPID: Int32, debugProxy: OpaquePointer, script: StikJIT.Script, progress: @escaping (String) -> Void) {
|
||||
self.targetPID = targetPID
|
||||
self.debugProxy = debugProxy
|
||||
self.script = script
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
let source = try BundledScript.universalJS()
|
||||
let source = try BundledScript.source(for: script)
|
||||
|
||||
guard let context = JSContext() else { throw StikJITError.scriptUnavailable }
|
||||
self.context = context
|
||||
|
||||
context.exceptionHandler = { [weak self] _, value in
|
||||
self?.progress("JS exception: \(value?.toString() ?? "<unknown>")")
|
||||
let detail = value?.toString() ?? "unknown JavaScript exception"
|
||||
self?.recordExecutionError(.scriptExecution(detail))
|
||||
}
|
||||
|
||||
let getPID: @convention(block) () -> Int = { [targetPID] in Int(targetPID) }
|
||||
|
|
@ -34,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() ?? "")
|
||||
|
|
@ -45,8 +51,9 @@ final class ScriptRunner {
|
|||
context.setObject(prepare, forKeyedSubscript: "prepare_memory_region" as NSString)
|
||||
context.setObject(log, forKeyedSubscript: "log" as NSString)
|
||||
|
||||
progress("Running universal.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).")
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +62,7 @@ final class ScriptRunner {
|
|||
defer { debugserver_command_free(handle) }
|
||||
var response: UnsafeMutablePointer<CChar>?
|
||||
if let error = debug_proxy_send_command(debugProxy, handle, &response) {
|
||||
progress("send_command error: \(IdeviceFFI.consume(error, fallback: "send_command").localizedDescription)")
|
||||
recordExecutionError(IdeviceFFI.consume(error, fallback: "send_command"))
|
||||
return nil
|
||||
}
|
||||
guard let response else { return "" }
|
||||
|
|
@ -65,17 +72,87 @@ final class ScriptRunner {
|
|||
|
||||
private func prepareMemoryRegion(_ address: UInt64, length: UInt64) -> String? {
|
||||
guard length > 0 else { return "OK" }
|
||||
let pageCount = (length - 1) / Self.jitPageSize + 1
|
||||
for page in 0..<pageCount {
|
||||
let pageAddress = address + page * Self.jitPageSize
|
||||
let pageCount = Int((length - 1) / Self.jitPageSize + 1)
|
||||
|
||||
let response = sendCommand("M\(String(pageAddress, radix: 16)),1:69")
|
||||
if let response, !(response.hasPrefix("OK") || response.isEmpty) {
|
||||
progress("page bless failed @0x\(String(pageAddress, radix: 16)): \(response)")
|
||||
let commandBuffer = Self.makeBlessCommands(startAddress: address, pageCount: pageCount)
|
||||
|
||||
for batchStart in stride(from: 0, to: pageCount, by: Self.commandsPerBatch) {
|
||||
let commandsInBatch = min(Self.commandsPerBatch, pageCount - batchStart)
|
||||
let byteOffset = batchStart * Self.jitPageCommandLength
|
||||
let byteCount = commandsInBatch * Self.jitPageCommandLength
|
||||
|
||||
let sendError = commandBuffer.withUnsafeBytes { rawBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
|
||||
let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress!
|
||||
return debug_proxy_send_raw(debugProxy, base.advanced(by: byteOffset), UInt(byteCount))
|
||||
}
|
||||
if let sendError {
|
||||
recordExecutionError(IdeviceFFI.consume(sendError, fallback: "debug_proxy_send_raw"))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _ in 0..<commandsInBatch {
|
||||
var response: UnsafeMutablePointer<CChar>?
|
||||
let readError = debug_proxy_read_response(debugProxy, &response)
|
||||
if let response {
|
||||
idevice_string_free(response)
|
||||
}
|
||||
if let readError {
|
||||
recordExecutionError(IdeviceFFI.consume(readError, fallback: "debug_proxy_read_response"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress("Blessed \(pageCount) JIT page(s) at 0x\(String(address, radix: 16))")
|
||||
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)
|
||||
|
||||
for page in 0..<pageCount {
|
||||
let pageAddress = startAddress + UInt64(page) * jitPageSize
|
||||
let start = page * jitPageCommandLength
|
||||
|
||||
buffer[start + 0] = UInt8(ascii: "$")
|
||||
buffer[start + 1] = UInt8(ascii: "M")
|
||||
writeHexAddress(pageAddress, into: &buffer, at: start + 2)
|
||||
buffer[start + 11] = UInt8(ascii: ",")
|
||||
buffer[start + 12] = UInt8(ascii: "1")
|
||||
buffer[start + 13] = UInt8(ascii: ":")
|
||||
buffer[start + 14] = UInt8(ascii: "6")
|
||||
buffer[start + 15] = UInt8(ascii: "9")
|
||||
buffer[start + 16] = UInt8(ascii: "#")
|
||||
writeChecksum(into: &buffer, bodyStart: start + 1, hashIndex: start + 16)
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private static func writeHexAddress(_ address: UInt64, into buffer: inout [UInt8], at index: Int) {
|
||||
for nibble in 0..<9 {
|
||||
let shift = UInt64((8 - nibble) * 4)
|
||||
buffer[index + nibble] = hexDigit(UInt8((address >> shift) & 0xf))
|
||||
}
|
||||
}
|
||||
|
||||
private static func writeChecksum(into buffer: inout [UInt8], bodyStart: Int, hashIndex: Int) {
|
||||
var checksum: UInt8 = 0
|
||||
for index in bodyStart..<hashIndex {
|
||||
checksum &+= buffer[index]
|
||||
}
|
||||
buffer[hashIndex + 1] = hexDigit((checksum & 0xf0) >> 4)
|
||||
buffer[hashIndex + 2] = hexDigit(checksum & 0x0f)
|
||||
}
|
||||
|
||||
private static func hexDigit(_ value: UInt8) -> UInt8 {
|
||||
value < 10 ? value + UInt8(ascii: "0") : value - 10 + UInt8(ascii: "a")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,188 @@ import Foundation
|
|||
|
||||
public enum StikJIT {
|
||||
|
||||
public enum Script: Sendable {
|
||||
|
||||
case universal
|
||||
|
||||
case legacy
|
||||
|
||||
case custom(URL)
|
||||
|
||||
case customBase64(String)
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .universal: return "universal"
|
||||
case .legacy: return "legacy"
|
||||
case .custom(let url): return url.lastPathComponent
|
||||
case .customBase64: return "custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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, progress: progress)
|
||||
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(paths: paths) { 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,
|
||||
configuration: Configuration = .default) throws -> Bool {
|
||||
try DDISession(pairingFilePath: pairingFile.path, configuration: configuration).isMounted()
|
||||
}
|
||||
|
||||
@available(iOS 17.4, *)
|
||||
public static func downloadDDIIfNeeded(to paths: DDIPaths,
|
||||
progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
|
||||
try await DeveloperDiskImageService.shared.downloadIfNeeded(to: paths, progress: progress)
|
||||
}
|
||||
|
||||
public static func mountDDI(pairingFile: URL,
|
||||
configuration: Configuration = .default,
|
||||
paths: DDIPaths,
|
||||
progress: @escaping (Double) -> Void = { _ in }) throws {
|
||||
let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration)
|
||||
try session.mountDDI(paths: paths, progress: progress)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,44 @@ public enum StikJITError: Error, LocalizedError {
|
|||
|
||||
case device(code: Int32, subCode: Int32, message: String)
|
||||
|
||||
case ddiDownload(String)
|
||||
|
||||
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):
|
||||
return "Pairing file error: \(detail)."
|
||||
case .scriptUnavailable:
|
||||
return "The bundled universal.js could not be loaded from the StikJIT framework."
|
||||
return "The bundled JIT script could not be loaded from the StikJIT framework."
|
||||
case .debugProxyUnavailable:
|
||||
return "Failed to establish a debugserver connection to the target process."
|
||||
case .device(let code, let subCode, let message):
|
||||
return "\(message) (idevice code \(code)/\(subCode))."
|
||||
case .ddiDownload(let detail):
|
||||
return "Failed to download Developer Disk Image: \(detail)."
|
||||
case .ddiFilesMissing(let detail):
|
||||
return "Developer Disk Image files missing: \(detail)."
|
||||
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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
Sources/SynchronousDDIDownloader.swift
Normal file
60
Sources/SynchronousDDIDownloader.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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")
|
||||
}
|
||||
if DDIMountMethod.current == .personalized {
|
||||
try paths.removeCryptexOnlyFiles()
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
3733
idevice/idevice.h
3733
idevice/idevice.h
File diff suppressed because it is too large
Load diff
Binary file not shown.
1
iokit/StikJITIOKit.h
Normal file
1
iokit/StikJITIOKit.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include <IOKit/IOKitLib.h>
|
||||
4
iokit/module.modulemap
Normal file
4
iokit/module.modulemap
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
module StikJITIOKit [system] {
|
||||
header "StikJITIOKit.h"
|
||||
export *
|
||||
}
|
||||
21
project.yml
21
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.9.0"
|
||||
CURRENT_PROJECT_VERSION: "10"
|
||||
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
|
||||
|
|
@ -33,12 +34,18 @@ targets:
|
|||
DEFINES_MODULE: YES
|
||||
BUILD_LIBRARY_FOR_DISTRIBUTION: YES
|
||||
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"
|
||||
# Embed only the idevice code reachable from StikJIT. Keep archive symbols
|
||||
# private so unused FFI/Rust exports do not become dead-strip roots.
|
||||
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"
|
||||
OTHER_LDFLAGS: "$(inherited) -Wl,-hidden-lidevice_ffi"
|
||||
DEAD_CODE_STRIPPING: YES
|
||||
configs:
|
||||
Release:
|
||||
# Remove private symbol names from the shipped binary; keep the dSYM.
|
||||
STRIP_INSTALLED_PRODUCT: YES
|
||||
STRIP_STYLE: non-global
|
||||
|
||||
schemes:
|
||||
StikJIT:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue