Compare commits

...

48 commits
3.1.8 ... main

Author SHA1 Message Date
CelloSerenity
1256c52803
Fix DeepWiki embed
Some checks are pending
Build Debug IPA / Build IPA (push) Waiting to run
Build Debug IPA / Update website source (push) Blocked by required conditions
2026-10-15 11:41:27 -04:00
CelloSerenity
3422fc4725
3.1.15 2026-10-15 10:30:45 -04:00
CelloSerenity
734361bb9d fix: handle legacy and cryptex DDI service discovery 2026-10-14 23:26:00 -04:00
CelloSerenity
cd5e609387
Update README.md 2026-10-12 06:56:38 -06:00
CelloSerenity
e4665d74b5
Update SettingsView.swift 2026-10-12 06:56:17 -06:00
CelloSerenity
300ebff50b
3.1.14 2026-10-11 21:13:08 -06:00
CelloSerenity
775fba15f3
Update AutoScriptAssignments.swift 2026-10-10 17:53:12 -06:00
CelloSerenity
871f1461d9
Update README.md 2026-10-04 14:09:38 -06:00
CelloSerenity
4bdfc92aa7
3.1.13 2026-09-28 08:29:15 -06:00
CelloSerenity
4b3c8d6e88
Rename 'MeloCafe' to 'MeloCafé' 2026-09-28 08:24:29 -06:00
c03abcaa44
Update AutoScriptAssignments.swift 2026-09-28 07:32:03 -04:00
CelloSerenity
d4306e724b
3.1.12 ReadMe 2026-09-27 15:26:08 -06:00
CelloSerenity
50766f5ac5
3.1.12 2026-09-27 14:55:57 -06:00
CelloSerenity
e7ef9491e2 fix: preserve background activity sessions 2026-09-27 14:52:35 -06:00
CelloSerenity
6a078be17e fix: require a background keep alive 2026-09-27 13:54:35 -06:00
CelloSerenity
2d09a405de fix: limit idevice log file size 2026-09-27 13:46:33 -06:00
CelloSerenity
f50353c643 perf: reduce redundant device queries 2026-09-27 13:26:07 -06:00
CelloSerenity
cbaf467b66 fix: restore personalized DDI mounting 2026-09-27 13:16:09 -06:00
CelloSerenity
534b6daa3f
Update AutoScriptAssignments.swift 2026-09-25 17:49:59 -06:00
CelloSerenity
5e3e1bc91e
3.1.11 2026-09-23 17:22:17 -06:00
CelloSerenity
3abf9d5b19 feat: warn before manually enabling LiveContainer JIT 2026-09-23 17:09:48 -06:00
CelloSerenity
e85e0b3d47 feat: smooth cryptex DDI migration 2026-09-23 17:09:26 -06:00
CelloSerenity
f76672838b feat: migrate to cryptex DDI 2026-09-22 10:26:20 -06:00
levi
94bc9e8cf3
Add Malloc Debugging (#460)
* added malloc implementation

* update malloc debugging label in settings

---------

Co-authored-by: Leviidev <Leviidev@users.noreply.github.com>
2026-09-09 08:52:58 -04:00
CelloSerenity
c5bad70d37 ci: integrate source update into build workflow 2026-08-27 11:38:07 -06:00
Stephen
b916fe51cc fix: prevent stalled tunnel connections from exhausting threads 2026-08-27 09:18:18 -04:00
CelloSerenity
b3e3096b63
chore: update actions dep because node 24 2026-08-26 22:16:28 -06:00
CelloSerenity
8f0439cd0c
Update AutoScriptAssignments.swift 2026-08-26 22:05:53 -06:00
CelloSerenity
460fb235c2
Fix uncomfortable blue line 2026-08-25 16:32:41 -06:00
Stephen
f5d31fe2b7 Bump version to 3.1.10 2026-08-25 12:42:58 -04:00
Stephen
70d31322e7 Fix DDI mount watchdog crash (hopefully) 2026-08-25 11:08:54 -04:00
CelloSerenity
66e359c7e2
Update README.md 2026-08-19 16:09:45 -06:00
CelloSerenity
ae460a7a5d
Fix extra line 2026-08-07 15:07:15 -06:00
CelloSerenity
8fdfb193e0
Fix image links in README.md 2026-08-07 15:00:40 -06:00
CelloSerenity
2354795117
Update AutoScriptAssignments.swift 2026-08-05 16:15:47 -06:00
CelloSerenity
a8a4892a8c fix: target Website source workflow 2026-08-03 18:16:14 -06:00
CelloSerenity
cf4108fc2c ci: update website source workflow 2026-08-03 18:13:31 -06:00
30cfa58785
Update download links and images in README.md 2026-08-03 14:27:51 -04:00
15d99027c0
Update GitHub URLs in SettingsView.swift 2026-08-03 12:44:37 -04:00
38c0ca1348
Update links to reflect new GitHub repository 2026-08-03 12:34:07 -04:00
e160c9e90a
Update AutoScriptAssignments.swift 2026-08-03 11:58:19 -04:00
CelloSerenity
f928790e7d
Update AutoScriptAssignments.swift 2026-08-01 16:53:07 -06:00
CelloSerenity
30a147aaaf ci: wait for tagged IPA builds 2026-08-01 14:28:22 -06:00
CelloSerenity
c7e08e64fd
Upgrade action-gh-release to version 3 2026-08-01 14:13:29 -06:00
CelloSerenity
85fb1350a4
3.1.9 2026-08-01 14:08:49 -06:00
CelloSerenity
ff030b809a Shorten IPA artifact SHA 2026-08-01 09:45:44 -06:00
CelloSerenity
22e34051ff Publish IPA releases from tags 2026-08-01 09:36:22 -06:00
CelloSerenity
ea42d847d1 Sync pairing files from Documents 2026-08-01 08:41:16 -06:00
28 changed files with 4581 additions and 375 deletions

22
.github/scripts/update_source.sh vendored Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
set -euo pipefail
website_directory=${1:?Usage: update_source.sh WEBSITE_DIRECTORY RELEASE_VERSION}
release_version=${2:?Usage: update_source.sh WEBSITE_DIRECTORY RELEASE_VERSION}
cd "$website_directory"
git config user.name 'GitHub Action'
git config user.email 'action@github.com'
python3 updatesource.py index.json
git add index.json
if git diff --staged --quiet; then
echo 'Website/index.json is already current.'
exit 0
fi
git commit -m "chore: update StikDebug source for $release_version"
git push origin main

View file

@ -3,17 +3,20 @@ name: Build Debug IPA
on: on:
push: push:
branches: [ "main" ] branches: [ "main" ]
tags: [ "[0-9]*" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: write
jobs: jobs:
build: build:
name: Build IPA name: Build IPA
runs-on: macos-latest runs-on: macos-latest
outputs:
version: ${{ steps.version.outputs.version }}
env: env:
UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'true' }} UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'true' }}
@ -50,10 +53,70 @@ jobs:
zip -r StikDebug.ipa Payload zip -r StikDebug.ipa Payload
rm -rf Payload StikDebug.app rm -rf Payload StikDebug.app
- name: 5. Store IPA as Workflow Artifact - name: 5. Name workflow artifact
id: artifact
if: env.UPLOAD_IPA == 'true'
run: |
file_name="StikDebug-${GITHUB_SHA::7}.ipa"
cp StikDebug.ipa "$file_name"
echo "file_name=$file_name" >> "$GITHUB_OUTPUT"
- name: 6. Store IPA as Workflow Artifact
if: env.UPLOAD_IPA == 'true' if: env.UPLOAD_IPA == 'true'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: StikDebug-Debug.ipa path: ${{ steps.artifact.outputs.file_name }}
path: StikDebug.ipa archive: false
retention-days: 90 retention-days: 90
- name: Read version
id: version
if: startsWith(github.ref, 'refs/tags/')
run: |
version=$(xcodebuild -project StikDebug.xcodeproj -scheme StikDebug -showBuildSettings | awk -F ' = ' '/ MARKETING_VERSION = / { print $2; exit }')
test -n "$version"
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
test "${GITHUB_REF_NAME#v}" = "$version"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Name release asset
if: startsWith(github.ref, 'refs/tags/')
run: mv StikDebug.ipa StikDebug-${{ steps.version.outputs.version }}.ipa
- name: Publish release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
files: StikDebug-${{ steps.version.outputs.version }}.ipa
generate_release_notes: true
fail_on_unmatched_files: true
update-source:
name: Update website source
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v6
- name: Checkout website
uses: actions/checkout@v6
with:
repository: StikDebug/Website
ref: main
path: Website
token: ${{ secrets.WEBSITE_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Update StikDebug source
run: .github/scripts/update_source.sh Website '${{ needs.build.outputs.version }}'

View file

@ -1,52 +0,0 @@
name: Update StikDebug Source
on:
release:
types: [published]
workflow_dispatch:
jobs:
update-source:
runs-on: ubuntu-latest
steps:
- name: Checkout Website-Stuff
uses: actions/checkout@v6
with:
repository: StephenDev0/Website-Stuff
ref: main
path: Website-Stuff
# A repository-scoped GITHUB_TOKEN cannot push to Website-Stuff.
token: ${{ secrets.WEBSITE_STUFF_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Update StikDebug source
id: update_source
working-directory: Website-Stuff
run: |
git config --global user.name 'GitHub Action'
git config --global user.email 'action@github.com'
python updatesource.py index.json
git add index.json
if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes detected in index.json"
else
git commit -m "Update StikDebug source for latest release"
git push origin main
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Create job summary
run: |
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
echo "## StikDebug source updated" >> $GITHUB_STEP_SUMMARY
echo "Website-Stuff/index.json was updated and pushed." >> $GITHUB_STEP_SUMMARY
else
echo "## StikDebug source already current" >> $GITHUB_STEP_SUMMARY
echo "No change to Website-Stuff/index.json was needed." >> $GITHUB_STEP_SUMMARY
fi

View file

@ -7,24 +7,17 @@
<p><i>An on-device debugger/JIT enabler for iOS versions 17.4+ powered by <a href="https://github.com/jkcoxson/idevice">idevice</a>.</i></p> <p><i>An on-device debugger/JIT enabler for iOS versions 17.4+ powered by <a href="https://github.com/jkcoxson/idevice">idevice</a>.</i></p>
</div> </div>
<h6 align="center"> <h3 align="center">
<a href="https://discord.gg/ZnNcrRT3M8"> <a href="https://discord.gg/ZnNcrRT3M8"><img src="https://img.shields.io/badge/Discord-join%20us-7289DA?logo=discord&logoColor=white&style=for-the-badge&labelColor=23272A" /></a>&nbsp;
<img src="https://img.shields.io/badge/Discord-join%20us-7289DA?logo=discord&logoColor=white&style=for-the-badge&labelColor=23272A" /> <a href="https://github.com/StikDebug/StikDebug/blob/main/LICENSE"><img src="https://img.shields.io/github/license/StikDebug/StikDebug?label=License&color=5865F2&style=for-the-badge&labelColor=23272A" /></a>&nbsp;
</a> <a href="https://github.com/StikDebug/StikDebug"><img src="https://img.shields.io/github/stars/StikDebug/StikDebug?label=Stars&color=FEE75C&style=for-the-badge&labelColor=23272A" /></a>&nbsp;
<a href="https://github.com/StephenDev0/StikDebug/blob/main/LICENSE"> <a href="https://github.com/StikDebug/StikDebug/releases"><img src="https://img.shields.io/github/v/release/StikDebug/StikDebug?label=Latest&color=00BFFF&style=for-the-badge&labelColor=23272A" /></a>&nbsp;
<img src="https://img.shields.io/github/license/StephenDev0/StikDebug?label=License&color=5865F2&style=for-the-badge&labelColor=23272A" /> <a href="https://deepwiki.com/StikDebug/StikDebug"><img src="https://img.shields.io/badge/Ask-DeepWiki-grey?style=for-the-badge&labelColor=23272A&color=007ec6" /></a>
</a> <br />
<a href="https://github.com/StephenDev0/StikDebug/stargazers"> </h3>
<img src="https://img.shields.io/github/stars/StephenDev0/StikDebug?label=Stars&color=FEE75C&style=for-the-badge&labelColor=23272A" />
</a>
<a href="https://github.com/StephenDev0/StikDebug/releases">
<img src="https://img.shields.io/github/v/release/StephenDev0/StikDebug?label=Latest&color=00BFFF&style=for-the-badge&labelColor=23272A" />
</a>
<br />
</h6>
## Features ## Features
- **JIT:** Enable Just In Time compilation for sideloaded apps that have the `get-task-allow` entitlement. - **JIT:** Enable Just-In-Time compilation for sideloaded apps that have the `get-task-allow` entitlement.
- **App Launching:** Launch every app installed on your device. - **App Launching:** Launch every app installed on your device.
- **Console:** Live app and system logs. - **Console:** Live app and system logs.
- **Scripts:** Manage automation scripts (mainly used for iOS 26 JIT). - **Scripts:** Manage automation scripts (mainly used for iOS 26 JIT).
@ -37,14 +30,11 @@
> [!NOTE] > [!NOTE]
> **Notice:** StikDebug is no longer available on the App Store. Please use the official download methods below. > **Notice:** StikDebug is no longer available on the App Store. Please use the official download methods below.
<div align="center" style="display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap;"> <h3 align="center">
<a href="https://stikstore.app/altdirect/?url=https://stikdebug.xyz/index.json" target="_blank"> <a href="https://altdirect.app/?url=https://stikdebug.xyz/index.json" target="_blank"><img src="https://altdirect.app/assets/png/AltSource_Blue.png" alt="Add AltSource" width="200"></a>
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/AltSource_Blue.png" alt="Add AltSource" width="200"> &nbsp;
</a> <a href="https://github.com/StikDebug/StikDebug/releases/latest/download/StikDebug-3.1.15.ipa" target="_blank"><img src="https://altdirect.app/assets/png/Download_Blue.png" alt="Download .ipa" width="200"></a>
<a href="https://github.com/StephenDev0/StikDebug/releases/download/3.1.6/StikDebug-3.1.6.ipa" target="_blank"> </h3>
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/Download_Blue.png" alt="Download .ipa" width="200">
</a>
</div>
## Compatibility ## Compatibility
@ -66,18 +56,18 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
### Steps ### Steps
1. **Obtain a pairing file** 1. **Obtain a pairing file**
- Detailed guide: [Pairing File Instructions](https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord). - Detailed guide: [Pairing File Instructions](https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord).
2. **Set up VPN** 2. **Set up VPN**
- Launch LocalDevVPN and enable the VPN. - Launch LocalDevVPN and enable the VPN.
4. **Enable JIT for an app** 4. **Enable JIT for an app**
- Launch StikDebug and tapp the `Enable JIT` button. - Launch StikDebug and tap the `Enable JIT` button.
- Select your sideloaded app from the list in StikDebug. - Select your sideloaded app from the list in StikDebug.
**Troubleshooting** **Troubleshooting**
- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings. - "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings.
- Heartbeat errors → Ensure that the VPN is on and that you are connecected to Wi-Fi. It may be a pairing file issue. - Heartbeat errors → Ensure that the VPN is on and that you are connected to Wi-Fi. It may be a pairing file issue.
- Pairing file issues → Replace file with device unlocked & trusted. - Pairing file issues → Replace file with device unlocked & trusted.
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots. - Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
@ -96,6 +86,10 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
## Building from Source ## Building from Source
> [!IMPORTANT]
> **Integrating StikDebug or StikJIT into your own project?**
> See the [StikJIT Integration Guide](https://github.com/StikDebug/StikJIT/blob/main/INTEGRATION.md) for integration instructions.
### Requirements ### Requirements
- macOS (latest recommended) - macOS (latest recommended)
- Xcode 16+ (Xcode 26+ preferred for iOS 26+ support) - Xcode 16+ (Xcode 26+ preferred for iOS 26+ support)
@ -106,7 +100,7 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
### Steps ### Steps
1. **Clone the repo** 1. **Clone the repo**
```bash ```bash
git clone https://github.com/StephenDev0/StikDebug.git git clone https://github.com/StikDebug/StikDebug.git
cd StikDebug cd StikDebug
``` ```
@ -146,13 +140,11 @@ To propose a new feature, open a feature request issue and provide:
### Code Contributions (Best Practices) ### Code Contributions (Best Practices)
- Follow normal Swift and SwiftUI style. - Follow normal Swift and SwiftUI style.
- Write clear and easy to understand code. - Write clear and easy-to-understand code.
- Keep your changes consistent with how the project is already set up. - Keep your changes consistent with how the project is already set up.
- Make sure everything builds and works without errors. - Make sure everything builds and works without errors.
We appreciate your time and effort in helping improve this project. We appreciate your time and effort in helping improve this project.
## Code Help
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/stephendev0/stikdebug)
## License ## License
StikDebug is licensed under **AGPL-3.0**. See [`LICENSE`](LICENSE) for details. StikDebug is licensed under **AGPL-3.0**. See [`LICENSE`](LICENSE) for details.

View file

@ -482,7 +482,7 @@
"$(PROJECT_DIR)/StikDebug/idevice", "$(PROJECT_DIR)/StikDebug/idevice",
); );
MACOSX_DEPLOYMENT_TARGET = 15.1; MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 3.1.8; MARKETING_VERSION = 3.1.15;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug; PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -544,7 +544,7 @@
"$(PROJECT_DIR)/StikDebug/idevice", "$(PROJECT_DIR)/StikDebug/idevice",
); );
MACOSX_DEPLOYMENT_TARGET = 15.1; MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 3.1.8; MARKETING_VERSION = 3.1.15;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug; PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -9,8 +9,9 @@ import UIKit
enum AppBootstrapper { enum AppBootstrapper {
static func configure() { static func configure() {
IdeviceLogFileManager.shared.prepareForLogging()
registerDefaultSettings() registerDefaultSettings()
startConfiguredKeepAliveServices() ensureKeepAliveSelection()
applyDocumentPickerCopyWorkaround() applyDocumentPickerCopyWorkaround()
} }
@ -27,11 +28,11 @@ enum AppBootstrapper {
]) ])
} }
private static func startConfiguredKeepAliveServices() { private static func ensureKeepAliveSelection() {
guard UserDefaults.standard.bool(forKey: "keepAliveAudio") else { let defaults = UserDefaults.standard
return if !defaults.bool(forKey: "keepAliveAudio"), !defaults.bool(forKey: "keepAliveLocation") {
defaults.set(true, forKey: "keepAliveAudio")
} }
BackgroundAudioManager.shared.start()
} }
private static func applyDocumentPickerCopyWorkaround() { private static func applyDocumentPickerCopyWorkaround() {

View file

@ -39,7 +39,8 @@ private enum IdeviceBridge {
} }
let code = Int(ffiError.pointee.code) let code = Int(ffiError.pointee.code)
let message = string(from: ffiError.pointee.message) ?? fallback let detail = string(from: ffiError.pointee.message) ?? fallback
let message = detail == fallback ? fallback : "\(fallback): \(detail)"
idevice_error_free(ffiError) idevice_error_free(ffiError)
return makeError(domain: domain, code: code, message: message) return makeError(domain: domain, code: code, message: message)
} }
@ -262,31 +263,69 @@ private enum IdeviceBridge {
} }
extension JITEnableContext { extension JITEnableContext {
func getMountedDeviceCount() throws -> Int { func isDeveloperDiskImageMounted() throws -> Bool {
try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in
try IdeviceBridge.withConnectedClient( var cryptexQuerySucceeded = false
fallback: "Failed to connect to image mounter", var cryptexQueryError: NSError?
missingClientMessage: "Image mounter client was not created", if DeveloperDiskImageService.usesCryptexDDI {
connect: { image_mounter_connect_rsd(adapter, handshake, $0) }, var installed: UnsafeMutablePointer<InstalledCryptexC>?
cleanup: { image_mounter_free($0) } defer { cryptexd_free_installed_cryptex(installed) }
) { client in if let ffiError = cryptexd_installed_ddi(adapter, handshake, &installed) {
var devices: UnsafeMutablePointer<plist_t?>? cryptexQueryError = IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to query installed DDI cryptex")
var deviceCount = 0 } else {
if let ffiError = image_mounter_copy_devices(client, &devices, &deviceCount) { cryptexQuerySucceeded = true
throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to fetch mounted devices") if installed != nil {
} return true
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 do {
return try IdeviceBridge.withConnectedClient(
fallback: "Failed to connect to image mounter",
missingClientMessage: "Image mounter client was not created",
connect: { image_mounter_connect_rsd(adapter, handshake, $0) },
cleanup: { image_mounter_free($0) }
) { client in
var devices: UnsafeMutablePointer<plist_t?>?
var deviceCount = 0
if let error = image_mounter_copy_devices(client, &devices, &deviceCount) {
throw IdeviceBridge.consumeFFIError(error, fallback: "Failed to fetch mounted devices")
}
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
}
} catch {
if cryptexQuerySucceeded {
return false
}
throw cryptexQueryError ?? (error as NSError)
}
}
}
func installCryptexDDI(from directoryPath: String) throws {
var assets: OpaquePointer?
if let ffiError = cryptex1_assets_load(directoryPath, &assets) {
throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to load DDI cryptex assets")
}
guard let assets else {
throw IdeviceBridge.makeError(message: "DDI cryptex assets were not loaded")
}
defer { cryptex1_assets_free(assets) }
try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in
if let ffiError = cryptexd_install_ddi(adapter, handshake, assets, nil) {
throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to install DDI cryptex")
} }
} }
} }
@ -307,13 +346,7 @@ extension JITEnableContext {
if let ffiError = lockdownd_get_value(lockdownClient, "UniqueChipID", nil, &uniqueChipIDPlist) { if let ffiError = lockdownd_get_value(lockdownClient, "UniqueChipID", nil, &uniqueChipIDPlist) {
throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to query UniqueChipID") throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to query UniqueChipID")
} }
defer { plist_free(uniqueChipIDPlist) }
defer {
if let uniqueChipIDPlist {
plist_free(uniqueChipIDPlist)
}
}
return try IdeviceBridge.uint64Value(from: uniqueChipIDPlist, fieldName: "UniqueChipID") return try IdeviceBridge.uint64Value(from: uniqueChipIDPlist, fieldName: "UniqueChipID")
} }
@ -323,10 +356,10 @@ extension JITEnableContext {
connect: { image_mounter_connect_rsd(adapter, handshake, $0) }, connect: { image_mounter_connect_rsd(adapter, handshake, $0) },
cleanup: { image_mounter_free($0) } cleanup: { image_mounter_free($0) }
) { imageMounterClient in ) { imageMounterClient in
let ffiError = imageData.withUnsafeBytes { imageBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in let ffiError = imageData.withUnsafeBytes { imageBuffer in
trustcacheData.withUnsafeBytes { trustcacheBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in trustcacheData.withUnsafeBytes { trustcacheBuffer in
manifestData.withUnsafeBytes { manifestBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in manifestData.withUnsafeBytes { manifestBuffer in
image_mounter_mount_personalized_with_callback_rsd( image_mounter_mount_personalized_rsd(
imageMounterClient, imageMounterClient,
adapter, adapter,
handshake, handshake,
@ -337,9 +370,7 @@ extension JITEnableContext {
manifestBuffer.bindMemory(to: UInt8.self).baseAddress, manifestBuffer.bindMemory(to: UInt8.self).baseAddress,
manifestData.count, manifestData.count,
nil, nil,
uniqueChipID, uniqueChipID
progressCallback,
nil
) )
} }
} }

View file

@ -17,6 +17,37 @@ typealias SyslogErrorHandler = (NSError?) -> Void
final class JITEnableContext { final class JITEnableContext {
static let shared = JITEnableContext() static let shared = JITEnableContext()
private static func withCStringArray<R>(
_ strings: [String], _ body: (UnsafePointer<UnsafePointer<CChar>?>?, UInt) -> R
) -> R {
if strings.isEmpty {
return body(nil, 0)
}
var cStrings: [UnsafeMutablePointer<CChar>?] = strings.map { strdup($0) }
defer { cStrings.forEach { free($0) } }
return cStrings.withUnsafeBufferPointer { buffer in
buffer.baseAddress!.withMemoryRebound(to: UnsafePointer<CChar>?.self, capacity: buffer.count) { rebound in
body(rebound, UInt(buffer.count))
}
}
}
private static func mallocDebugEnvVars() -> [String] {
let defaults = UserDefaults.standard
let enableAll = defaults.bool(forKey: UserDefaults.Keys.mallocDebug)
let guardEdges = enableAll || defaults.bool(forKey: UserDefaults.Keys.mallocGuardEdges)
let scribble = enableAll || defaults.bool(forKey: UserDefaults.Keys.mallocScribble)
var envVars: [String] = []
if guardEdges {
envVars.append("MallocGuardEdges=1")
}
if scribble {
envVars.append("MallocScribble=1")
}
return envVars
}
private struct TunnelHandles { private struct TunnelHandles {
var adapter: OpaquePointer? var adapter: OpaquePointer?
var handshake: OpaquePointer? var handshake: OpaquePointer?
@ -51,11 +82,7 @@ final class JITEnableContext {
var handshakeHandle: OpaquePointer? { handshake } var handshakeHandle: OpaquePointer? { handshake }
private init() { private init() {
let logURL = FileManager.default var path = Array(IdeviceLogFileManager.logURL.path.utf8CString)
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("idevice_log.txt")
var path = Array(logURL.path.utf8CString)
path.withUnsafeMutableBufferPointer { buffer in path.withUnsafeMutableBufferPointer { buffer in
_ = idevice_init_logger(Info, Debug, buffer.baseAddress) _ = idevice_init_logger(Info, Debug, buffer.baseAddress)
} }
@ -90,7 +117,8 @@ final class JITEnableContext {
guard let ffiError else { guard let ffiError else {
return makeError(fallback) return makeError(fallback)
} }
let message = nsString(from: ffiError.pointee.message, fallback: fallback) let detail = nsString(from: ffiError.pointee.message, fallback: fallback)
let message = detail == fallback ? fallback : "\(fallback): \(detail)"
let error = makeError(message, code: Int(ffiError.pointee.code)) let error = makeError(message, code: Int(ffiError.pointee.code))
idevice_error_free(ffiError) idevice_error_free(ffiError)
return error return error
@ -188,7 +216,9 @@ final class JITEnableContext {
tunnelLock.unlock() tunnelLock.unlock()
if let waitSemaphore { if let waitSemaphore {
waitSemaphore.wait() guard waitSemaphore.wait(timeout: .now() + .seconds(15)) == .success else {
throw makeError("Timed out waiting for the tunnel connection", code: -19)
}
waitSemaphore.signal() waitSemaphore.signal()
} }
@ -428,22 +458,19 @@ final class JITEnableContext {
private func withConnectedRemoteServer<T>( private func withConnectedRemoteServer<T>(
_ body: (OpaquePointer) throws -> T _ body: (OpaquePointer) throws -> T
) throws -> T { ) throws -> T {
try ensureTunnel() try withFreshDebugTunnel(hostname: "StikDebugRemoteServer") { adapter, handshake in
guard let adapter, let handshake else { var remoteServer: OpaquePointer?
throw makeError("Tunnel is not connected") if let ffiError = remote_server_connect_rsd(adapter, handshake, &remoteServer) {
} throw error(from: ffiError, fallback: "Failed to connect remote server")
}
var remoteServer: OpaquePointer? guard let remoteServer else {
if let ffiError = remote_server_connect_rsd(adapter, handshake, &remoteServer) { throw makeError("Remote server handle was not created")
throw error(from: ffiError, fallback: "Failed to connect remote server") }
}
guard let remoteServer else { defer { remote_server_free(remoteServer) }
throw makeError("Remote server handle was not created") return try body(remoteServer)
} }
defer { remote_server_free(remoteServer) }
return try body(remoteServer)
} }
private func withProcessControl<T>( private func withProcessControl<T>(
@ -601,7 +628,9 @@ final class JITEnableContext {
try withProcessControl(remoteServer: remoteServer) { processControl in try withProcessControl(remoteServer: remoteServer) { processControl in
var pid: UInt64 = 0 var pid: UInt64 = 0
let ffiError = bundleID.withCString { bundleID in let ffiError = bundleID.withCString { bundleID in
process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, true, false, &pid) Self.withCStringArray(Self.mallocDebugEnvVars()) { envPtr, envCount in
process_control_launch_app(processControl, bundleID, envPtr, envCount, nil, 0, true, false, &pid)
}
} }
if let ffiError { if let ffiError {

View file

@ -9,74 +9,108 @@ import idevice
final class MountingProgress: ObservableObject { final class MountingProgress: ObservableObject {
static let shared = MountingProgress() static let shared = MountingProgress()
@Published private(set) var mountProgress: Double = 0.0
@Published private(set) var mountingThread: Thread? @Published private(set) var mountingThread: Thread?
@Published private(set) var coolisMounted: Bool = false @Published private(set) var coolisMounted: Bool = false
private let mountCheckLock = NSLock()
private var mountCheckInProgress = false
private let mountLock = NSLock()
private var mountInProgress = false
private init() {} private init() {}
func checkforMounted() { func checkforMounted() {
DispatchQueue.global(qos: .utility).async { guard TunnelManager.shared.isConnected else { return }
let mounted = isMounted()
DispatchQueue.main.async {
self.coolisMounted = mounted
}
}
}
func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { mountCheckLock.lock()
let percentage = Double(progress) / Double(total) * 100.0 guard !mountCheckInProgress else {
DispatchQueue.main.async { mountCheckLock.unlock()
self.mountProgress = percentage return
}
mountCheckInProgress = true
mountCheckLock.unlock()
DispatchQueue.global(qos: .utility).async {
let status = checkMountStatus()
self.mountCheckLock.lock()
self.mountCheckInProgress = false
self.mountCheckLock.unlock()
DispatchQueue.main.async {
if status != .unreachable {
self.coolisMounted = status == .mounted
}
}
} }
} }
func pubMount() { func pubMount() {
mount() guard TunnelManager.shared.isConnected, DeveloperDiskImageService.filesAreReady else { return }
mountLock.lock()
guard !mountInProgress else {
mountLock.unlock()
return
}
mountInProgress = true
mountLock.unlock()
let thread = Thread { [weak self] in
self?.mount()
}
thread.qualityOfService = .background
thread.name = "mounting"
DispatchQueue.main.async {
self.mountingThread = thread
}
thread.start()
} }
private func mount() { private func mount() {
let currentlyMounted = isMounted() switch checkMountStatus() {
DispatchQueue.main.async { case .mounted:
self.coolisMounted = currentlyMounted finishMount {
self.coolisMounted = true
}
return
case .unreachable:
finishMount()
return
case .notMounted:
break
} }
guard isPairing(), !currentlyMounted else { guard isPairing() else {
finishMount()
return return
} }
if let mountingThread { let mountError = mountDeveloperDiskImage(from: DeveloperDiskImageService.directoryURL.path)
mountingThread.cancel() let mounted = mountError == nil || checkMountStatus() == .mounted
self.mountingThread = nil finishMount {
} if mounted {
self.coolisMounted = true
let thread = Thread { [weak self] in self.checkforMounted()
guard let self else { return } } else if let mountError {
let mountError = mountPersonalDDI( LogManager.shared.addErrorLog("Failed to mount DDI: \(mountError)")
imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path, showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in
trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path, if shouldTryAgain {
manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path self.pubMount()
)
DispatchQueue.main.async {
if let mountError {
showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in
if shouldTryAgain {
self.mount()
}
} }
} else {
self.coolisMounted = true
self.checkforMounted()
} }
self.mountingThread = nil
} }
} }
}
thread.qualityOfService = .background private func finishMount(_ completion: @escaping () -> Void = {}) {
thread.name = "mounting" DispatchQueue.main.async {
thread.start() self.mountingThread = nil
mountingThread = thread self.mountLock.lock()
self.mountInProgress = false
self.mountLock.unlock()
completion()
}
} }
} }

View file

@ -13,10 +13,6 @@ typealias RsdHandshakeHandle = OpaquePointer
typealias ImageMounterHandle = OpaquePointer typealias ImageMounterHandle = OpaquePointer
typealias LockdowndClientHandle = OpaquePointer typealias LockdowndClientHandle = OpaquePointer
func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
MountingProgress.shared.progressCallback(progress: progress, total: total, context: context)
}
enum MountCheckResult { enum MountCheckResult {
case mounted case mounted
case notMounted case notMounted
@ -29,18 +25,25 @@ func isMounted() -> Bool {
func checkMountStatus() -> MountCheckResult { func checkMountStatus() -> MountCheckResult {
do { do {
let result = try JITEnableContext.shared.getMountedDeviceCount() return try JITEnableContext.shared.isDeveloperDiskImageMounted() ? .mounted : .notMounted
return result > 0 ? .mounted : .notMounted
} catch { } catch {
LogManager.shared.addErrorLog("Failed to check DDI mount status: \(error.localizedDescription)")
return .unreachable return .unreachable
} }
} }
func mountPersonalDDI(imagePath: String, trustcachePath: String, manifestPath: String) -> String? { func mountDeveloperDiskImage(from directoryPath: String) -> String? {
do { do {
try JITEnableContext.shared.mountPersonalDDI(withImagePath: imagePath, trustcachePath: trustcachePath, manifestPath: manifestPath) if DeveloperDiskImageService.usesCryptexDDI {
try JITEnableContext.shared.installCryptexDDI(from: directoryPath)
} else {
try JITEnableContext.shared.mountPersonalDDI(
withImagePath: URL(fileURLWithPath: directoryPath).appendingPathComponent("Image.dmg").path,
trustcachePath: URL(fileURLWithPath: directoryPath).appendingPathComponent("Image.dmg.trustcache").path,
manifestPath: URL(fileURLWithPath: directoryPath).appendingPathComponent("BuildManifest.plist").path
)
}
} catch { } catch {
LogManager.shared.addErrorLog("Failed to mount DDI: \(error.localizedDescription)")
return error.localizedDescription return error.localizedDescription
} }
return nil return nil

View file

@ -20,13 +20,26 @@ final class InstalledAppsViewModel: ObservableObject {
private let cacheKeyDebuggable = "cachedDebuggableApps" private let cacheKeyDebuggable = "cachedDebuggableApps"
private let cacheKeyNonDebuggable = "cachedNonDebuggableApps" private let cacheKeyNonDebuggable = "cachedNonDebuggableApps"
private let cacheKeySystem = "cachedSystemApps" private let cacheKeySystem = "cachedSystemApps"
private var cancellables: Set<AnyCancellable> = []
private var refreshInProgress = false
private var refreshPending = false
init() { init() {
loadCachedApps() loadCachedApps()
refreshAppLists() TunnelManager.shared.$isConnected
.removeDuplicates()
.filter { $0 }
.sink { [weak self] _ in self?.refreshAppLists() }
.store(in: &cancellables)
} }
func refreshAppLists() { func refreshAppLists() {
guard !refreshInProgress else {
refreshPending = true
return
}
refreshInProgress = true
isLoading = true isLoading = true
lastError = nil lastError = nil
@ -42,6 +55,11 @@ final class InstalledAppsViewModel: ObservableObject {
debuggable: debuggable, debuggable: debuggable,
hiddenSystem: hiddenSystem hiddenSystem: hiddenSystem
) )
self.cacheApps(
debuggable: debuggable,
nonDebuggable: classifiedApps.nonDebuggable,
system: classifiedApps.system
)
DispatchQueue.main.async { DispatchQueue.main.async {
self.apply( self.apply(
@ -49,22 +67,26 @@ final class InstalledAppsViewModel: ObservableObject {
nonDebuggable: classifiedApps.nonDebuggable, nonDebuggable: classifiedApps.nonDebuggable,
system: classifiedApps.system system: classifiedApps.system
) )
self.isLoading = false self.finishRefresh()
self.cacheApps(
debuggable: debuggable,
nonDebuggable: classifiedApps.nonDebuggable,
system: classifiedApps.system
)
} }
} catch { } catch {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isLoading = false
self.lastError = error.localizedDescription self.lastError = error.localizedDescription
self.finishRefresh()
} }
} }
} }
} }
private func finishRefresh() {
refreshInProgress = false
isLoading = false
guard refreshPending else { return }
refreshPending = false
refreshAppLists()
}
func displayName(for bundleID: String) -> String? { func displayName(for bundleID: String) -> String? {
debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID] debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID]
} }

View file

@ -11,7 +11,6 @@ final class BackgroundAudioManager {
private var engine = AVAudioEngine() private var engine = AVAudioEngine()
private var player = AVAudioPlayerNode() private var player = AVAudioPlayerNode()
private var isRunning = false private var isRunning = false
private var persistentEnabled = false
private var activityCount = 0 private var activityCount = 0
private var healthCheckTimer: Timer? private var healthCheckTimer: Timer?
@ -30,13 +29,7 @@ final class BackgroundAudioManager {
) )
} }
func start() { func configurationDidChange() {
persistentEnabled = true
refreshRunningState()
}
func stop() {
persistentEnabled = false
refreshRunningState() refreshRunningState()
} }
@ -51,7 +44,7 @@ final class BackgroundAudioManager {
} }
private func refreshRunningState() { private func refreshRunningState() {
let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")) let shouldRun = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")
guard shouldRun != isRunning else { guard shouldRun != isRunning else {
if shouldRun { if shouldRun {
recoverIfNeeded() recoverIfNeeded()

View file

@ -4,6 +4,7 @@
// //
import CoreLocation import CoreLocation
import UIKit
final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
static let shared = BackgroundLocationManager() static let shared = BackgroundLocationManager()
@ -11,6 +12,7 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager() private let locationManager = CLLocationManager()
private var isRunning = false private var isRunning = false
private var activityCount = 0 private var activityCount = 0
private var isRequestingAuthorization = false
private override init() { private override init() {
super.init() super.init()
@ -21,8 +23,44 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
locationManager.pausesLocationUpdatesAutomatically = false locationManager.pausesLocationUpdatesAutomatically = false
} }
func start() { func configurationDidChange() {
isRunning = true refreshRunningState()
}
func requestStart() {
activityCount += 1
refreshRunningState()
}
func requestStop() {
activityCount = max(activityCount - 1, 0)
refreshRunningState()
}
func requestAuthorizationIfNeeded() {
switch locationManager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
return
case .notDetermined:
isRequestingAuthorization = true
locationManager.requestAlwaysAuthorization()
case .denied, .restricted:
restoreAudioFallback()
@unknown default:
restoreAudioFallback()
}
}
private func refreshRunningState() {
let shouldRun = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation")
guard shouldRun != isRunning else { return }
isRunning = shouldRun
guard shouldRun else {
locationManager.stopUpdatingLocation()
return
}
switch locationManager.authorizationStatus { switch locationManager.authorizationStatus {
case .authorizedAlways: case .authorizedAlways:
locationManager.startUpdatingLocation() locationManager.startUpdatingLocation()
@ -30,37 +68,54 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
locationManager.requestAlwaysAuthorization() locationManager.requestAlwaysAuthorization()
case .notDetermined: case .notDetermined:
locationManager.requestAlwaysAuthorization() locationManager.requestAlwaysAuthorization()
default: case .denied, .restricted:
break restoreAudioFallback()
} @unknown default:
} restoreAudioFallback()
func stop() {
isRunning = false
locationManager.stopUpdatingLocation()
}
func requestStart() {
activityCount += 1
if activityCount == 1, UserDefaults.standard.bool(forKey: "keepAliveLocation") {
start()
}
}
func requestStop() {
activityCount = max(activityCount - 1, 0)
if activityCount == 0 {
stop()
} }
} }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard isRunning else { return }
switch manager.authorizationStatus { switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse:
manager.startUpdatingLocation() isRequestingAuthorization = false
default: if isRunning {
break manager.startUpdatingLocation()
}
case .denied, .restricted:
if isRunning || isRequestingAuthorization {
isRequestingAuthorization = false
restoreAudioFallback()
}
case .notDetermined:
return
@unknown default:
if isRunning || isRequestingAuthorization {
isRequestingAuthorization = false
restoreAudioFallback()
}
}
}
private func restoreAudioFallback() {
let defaults = UserDefaults.standard
defaults.set(false, forKey: "keepAliveLocation")
defaults.set(true, forKey: "keepAliveAudio")
refreshRunningState()
BackgroundAudioManager.shared.configurationDidChange()
showAlert(
title: "Location Access Refused",
message: "Location access was refused. Open Settings to enable it.",
showOk: false,
showTryAgain: true,
primaryButtonText: "Settings"
) { openSettings in
guard openSettings,
let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
return
}
UIApplication.shared.open(settingsURL)
} }
} }

View file

@ -7,6 +7,17 @@ import Foundation
final class DeveloperDiskImageService { final class DeveloperDiskImageService {
static let shared = DeveloperDiskImageService() static let shared = DeveloperDiskImageService()
static let directoryURL = URL.documentsDirectory.appendingPathComponent("DDI", isDirectory: true)
static var filesAreReady: Bool {
downloadItems.allSatisfy {
FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent($0.fileName).path)
} && (try? String(contentsOf: mountMethodURL, encoding: .utf8)) == mountMethod.rawValue
}
static var usesCryptexDDI: Bool {
mountMethod == .cryptex
}
private let fileManager: FileManager private let fileManager: FileManager
private let session: URLSession private let session: URLSession
@ -17,13 +28,21 @@ final class DeveloperDiskImageService {
} }
func downloadMissingFiles() async throws { func downloadMissingFiles() async throws {
let installedMethod = try? String(contentsOf: Self.mountMethodURL, encoding: .utf8)
let replaceFiles = installedMethod != Self.mountMethod.rawValue
if !Self.usesCryptexDDI {
try removeCryptexOnlyFiles()
}
for item in Self.downloadItems { for item in Self.downloadItems {
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) let destinationURL = Self.directoryURL.appendingPathComponent(item.fileName)
guard !fileManager.fileExists(atPath: destinationURL.path) else { guard replaceFiles || !fileManager.fileExists(atPath: destinationURL.path) else {
continue continue
} }
try await downloadFile(from: item.urlString, to: destinationURL) try await downloadFile(from: item.urlString, to: destinationURL)
} }
try Self.mountMethod.rawValue.write(to: Self.mountMethodURL, atomically: true, encoding: .utf8)
} }
func downloadFile(from urlString: String, to destinationURL: URL) async throws { func downloadFile(from urlString: String, to destinationURL: URL) async throws {
@ -56,8 +75,18 @@ final class DeveloperDiskImageService {
var completedStages = 0.0 var completedStages = 0.0
progressHandler?(0.0, "Removing existing DDI files...") progressHandler?(0.0, "Removing existing DDI files...")
if fileManager.fileExists(atPath: Self.mountMethodURL.path) {
try fileManager.removeItem(at: Self.mountMethodURL)
}
if !Self.usesCryptexDDI {
try removeCryptexOnlyFiles()
}
let completionMarker = Self.directoryURL.appendingPathComponent("Image.dmg.root_hash")
if fileManager.fileExists(atPath: completionMarker.path) {
try fileManager.removeItem(at: completionMarker)
}
for item in Self.downloadItems { for item in Self.downloadItems {
let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) let fileURL = Self.directoryURL.appendingPathComponent(item.fileName)
if fileManager.fileExists(atPath: fileURL.path) { if fileManager.fileExists(atPath: fileURL.path) {
try fileManager.removeItem(at: fileURL) try fileManager.removeItem(at: fileURL)
} }
@ -68,37 +97,100 @@ final class DeveloperDiskImageService {
for item in Self.downloadItems { for item in Self.downloadItems {
progressHandler?(completedStages / totalStages, "Downloading \(item.name)...") progressHandler?(completedStages / totalStages, "Downloading \(item.name)...")
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) let destinationURL = Self.directoryURL.appendingPathComponent(item.fileName)
try await downloadFile(from: item.urlString, to: destinationURL) try await downloadFile(from: item.urlString, to: destinationURL)
completedStages += 1.0 completedStages += 1.0
progressHandler?(completedStages / totalStages, "\(item.name) ready") progressHandler?(completedStages / totalStages, "\(item.name) ready")
} }
try Self.mountMethod.rawValue.write(to: Self.mountMethodURL, atomically: true, encoding: .utf8)
progressHandler?(1.0, "DDI download complete.") progressHandler?(1.0, "DDI download complete.")
} }
private static let downloadItems: [DDIDownloadItem] = [ private func removeCryptexOnlyFiles() throws {
for fileName in Self.cryptexOnlyFileNames {
let fileURL = Self.directoryURL.appendingPathComponent(fileName)
if fileManager.fileExists(atPath: fileURL.path) {
try fileManager.removeItem(at: fileURL)
}
}
}
private static var mountMethod: DDIMountMethod {
let version = ProcessInfo.processInfo.operatingSystemVersion
return version.majorVersion > 26 || (version.majorVersion == 26 && version.minorVersion >= 4)
? .cryptex
: .personalized
}
private static let mountMethodURL = directoryURL.appendingPathComponent("MountMethod")
private static let cryptexOnlyFileNames = ["Image.dmg.cryptex_info", "Image.dmg.root_hash"]
private static var downloadItems: [DDIDownloadItem] {
switch mountMethod {
case .cryptex:
return cryptexDownloadItems
case .personalized:
return personalizedDownloadItems
}
}
private static let cryptexDownloadItems: [DDIDownloadItem] = [
.init( .init(
name: "Build Manifest", name: "Build Manifest",
relativePath: "DDI/BuildManifest.plist", fileName: "BuildManifest.plist",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex/BuildManifest.plist"
),
.init(
name: "Image",
fileName: "Image.dmg",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex/Image.dmg"
),
.init(
name: "TrustCache",
fileName: "Image.dmg.trustcache",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex/Image.dmg.trustcache"
),
.init(
name: "Cryptex Info",
fileName: "Image.dmg.cryptex_info",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex/Image.dmg.cryptex_info"
),
.init(
name: "Root Hash",
fileName: "Image.dmg.root_hash",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Cryptex/Image.dmg.root_hash"
)
]
private static let personalizedDownloadItems: [DDIDownloadItem] = [
.init(
name: "Build Manifest",
fileName: "BuildManifest.plist",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist"
), ),
.init( .init(
name: "Image", name: "Image",
relativePath: "DDI/Image.dmg", fileName: "Image.dmg",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg"
), ),
.init( .init(
name: "TrustCache", name: "TrustCache",
relativePath: "DDI/Image.dmg.trustcache", fileName: "Image.dmg.trustcache",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache"
) )
] ]
} }
private enum DDIMountMethod: String {
case cryptex
case personalized
}
private struct DDIDownloadItem { private struct DDIDownloadItem {
let name: String let name: String
let relativePath: String let fileName: String
let urlString: String let urlString: String
} }

View file

@ -0,0 +1,66 @@
//
// IdeviceLogFileManager.swift
// StikDebug
//
import Foundation
final class IdeviceLogFileManager {
static let shared = IdeviceLogFileManager()
static let logURL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt")
private static let maximumSize: UInt64 = 20 * 1024 * 1024
private static let retainedSize: UInt64 = 10 * 1024 * 1024
private static let checkInterval: TimeInterval = 5
private let queue = DispatchQueue(label: "com.stik.ideviceLogFile", qos: .utility)
private var timer: DispatchSourceTimer?
private init() {}
func prepareForLogging() {
compactIfNeeded()
queue.async {
guard self.timer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + Self.checkInterval, repeating: Self.checkInterval)
timer.setEventHandler { [weak self] in
self?.compactIfNeeded()
}
self.timer = timer
timer.resume()
}
}
private func compactIfNeeded() {
let fileManager = FileManager.default
guard let attributes = try? fileManager.attributesOfItem(atPath: Self.logURL.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.uint64Value > Self.maximumSize,
let handle = try? FileHandle(forUpdating: Self.logURL) else {
return
}
defer { try? handle.close() }
do {
let endOffset = try handle.seekToEnd()
let startOffset = endOffset > Self.retainedSize ? endOffset - Self.retainedSize : 0
try handle.seek(toOffset: startOffset)
var recentData = try handle.readToEnd() ?? Data()
if startOffset > 0, let newlineIndex = recentData.firstIndex(of: 0x0A) {
recentData.removeSubrange(recentData.startIndex...newlineIndex)
}
try handle.truncate(atOffset: 0)
try handle.seek(toOffset: 0)
try handle.write(contentsOf: recentData)
try handle.synchronize()
} catch {
return
}
}
}

View file

@ -0,0 +1,68 @@
import Foundation
import CoreLocation
import Combine
final class LocationSimulationSession: ObservableObject {
static let shared = LocationSimulationSession()
@Published private(set) var isActive = false
@Published private(set) var coordinate: CLLocationCoordinate2D?
@Published private(set) var routeCoordinates: [CLLocationCoordinate2D]?
private var resendTimer: Timer?
private var routeID: UUID?
private init() {}
func start(at coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
if !isActive {
isActive = true
BackgroundAudioManager.shared.requestStart()
BackgroundLocationManager.shared.requestStart()
}
}
func startResending(at coordinate: CLLocationCoordinate2D, _ operation: @escaping () -> Void) {
start(at: coordinate)
resendTimer?.invalidate()
let timer = Timer(timeInterval: 4, repeats: true) { _ in operation() }
RunLoop.main.add(timer, forMode: .common)
resendTimer = timer
}
func startRoute(at coordinate: CLLocationCoordinate2D, coordinates: [CLLocationCoordinate2D]) -> UUID {
let routeID = UUID()
self.routeID = routeID
routeCoordinates = coordinates
start(at: coordinate)
return routeID
}
func isCurrentRoute(_ routeID: UUID) -> Bool {
self.routeID == routeID
}
func clearRoute() {
routeID = nil
routeCoordinates = nil
}
func pauseResending() {
resendTimer?.invalidate()
resendTimer = nil
}
func updateCoordinate(_ coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
func stop() {
pauseResending()
clearRoute()
guard isActive else { return }
isActive = false
coordinate = nil
BackgroundAudioManager.shared.requestStop()
BackgroundLocationManager.shared.requestStop()
}
}

View file

@ -70,12 +70,7 @@ final class TunnelManager: ObservableObject {
} }
private func mountDeveloperDiskImageIfNeeded() { private func mountDeveloperDiskImageIfNeeded() {
let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path guard DeveloperDiskImageService.filesAreReady else { return }
guard FileManager.default.fileExists(atPath: trustcachePath),
!MountingProgress.shared.coolisMounted,
MountingProgress.shared.mountingThread == nil else {
return
}
MountingProgress.shared.pubMount() MountingProgress.shared.pubMount()
} }

View file

@ -14,13 +14,17 @@ extension ScriptStore {
appNames: [ appNames: [
"Amethyst", "Amethyst",
"MeloNX", "MeloNX",
"Melo",
"XeniOS", "XeniOS",
"MeloCafe", "MeloCafé",
"Manic EMU", "Manic EMU",
"Manic",
"DukeX", "DukeX",
"TachyonU", "TachyonU",
"touchHLE",
"HyperHLE",
"Applesauce",
"RPCS3",
"AetherPS4",
"Husk"
], ],
resource: ScriptResource(resourceName: "universal", fileName: "universal.js") resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
), ),

View file

@ -7,14 +7,14 @@ import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
enum PairingFileStore { enum PairingFileStore {
static let fileName = "rp_pairing_file.plist" static let fileName = "pairingFile.plist"
static let supportedContentTypes: [UTType] = [ static let supportedContentTypes: [UTType] = [
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
.propertyList .propertyList
] ]
private static let legacyFileName = "pairingFile.plist" private static let legacyFileName = "rp_pairing_file.plist"
static var url: URL { static var url: URL {
directoryURL.appendingPathComponent(fileName) directoryURL.appendingPathComponent(fileName)
@ -25,31 +25,31 @@ enum PairingFileStore {
let destination = url let destination = url
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
guard !fileManager.fileExists(atPath: destination.path) else { if let documentURL = documentSourceURL(fileManager: fileManager) {
removeLegacyCopies(fileManager: fileManager) if !fileManager.fileExists(atPath: destination.path) ||
!fileManager.contentsEqual(atPath: documentURL.path, andPath: destination.path) {
try? replaceItem(at: destination, with: documentURL, fileManager: fileManager)
protectPairingFile(at: destination, fileManager: fileManager)
}
return destination return destination
} }
migrateLegacyCopy(to: destination, fileManager: fileManager) if !fileManager.fileExists(atPath: destination.path) {
migrateLegacyCopy(to: destination, fileManager: fileManager)
}
return destination return destination
} }
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws { static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
let destination = prepareURL(fileManager: fileManager) try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let tempURL = directoryURL.appendingPathComponent(UUID().uuidString + ".tmp") let documentsDestination = documentsURL
try? fileManager.removeItem(at: tempURL) if sourceURL.standardizedFileURL != documentsDestination.standardizedFileURL {
try fileManager.copyItem(at: sourceURL, to: tempURL) try replaceItem(at: documentsDestination, with: sourceURL, fileManager: fileManager)
defer { try? fileManager.removeItem(at: tempURL) }
if fileManager.fileExists(atPath: destination.path) {
_ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
} else {
try fileManager.moveItem(at: tempURL, to: destination)
} }
removeLegacyCopies(fileManager: fileManager) try replaceItem(at: url, with: documentsDestination, fileManager: fileManager)
protectPairingFile(at: destination, fileManager: fileManager) protectPairingFile(at: url, fileManager: fileManager)
} }
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
@ -83,19 +83,33 @@ enum PairingFileStore {
private static var legacyURLs: [URL] { private static var legacyURLs: [URL] {
[ [
URL.documentsDirectory.appendingPathComponent(fileName), directoryURL.appendingPathComponent(legacyFileName),
documentsURL,
URL.documentsDirectory.appendingPathComponent(legacyFileName) URL.documentsDirectory.appendingPathComponent(legacyFileName)
] ]
} }
private static var documentsURL: URL {
URL.documentsDirectory.appendingPathComponent(fileName)
}
private static func documentSourceURL(fileManager: FileManager) -> URL? {
[documentsURL, URL.documentsDirectory.appendingPathComponent(legacyFileName)]
.filter { fileManager.fileExists(atPath: $0.path) }
.max {
let firstDate = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
let secondDate = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
return firstDate < secondDate
}
}
private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) { private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) {
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
do { do {
try fileManager.moveItem(at: legacyURL, to: destination) try replaceItem(at: destination, with: legacyURL, fileManager: fileManager)
} catch { } catch {
if let data = try? Data(contentsOf: legacyURL) { if let data = try? Data(contentsOf: legacyURL) {
try? data.write(to: destination, options: .atomic) try? data.write(to: destination, options: .atomic)
try? fileManager.removeItem(at: legacyURL)
} }
} }
@ -104,6 +118,21 @@ enum PairingFileStore {
} }
} }
private static func replaceItem(at destination: URL, with source: URL, fileManager: FileManager) throws {
let temporary = destination
.deletingLastPathComponent()
.appendingPathComponent(UUID().uuidString + ".tmp")
try? fileManager.removeItem(at: temporary)
try fileManager.copyItem(at: source, to: temporary)
defer { try? fileManager.removeItem(at: temporary) }
if fileManager.fileExists(atPath: destination.path) {
_ = try fileManager.replaceItemAt(destination, withItemAt: temporary)
} else {
try fileManager.moveItem(at: temporary, to: destination)
}
}
private static func removeLegacyCopies(fileManager: FileManager) { private static func removeLegacyCopies(fileManager: FileManager) {
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
try? fileManager.removeItem(at: legacyURL) try? fileManager.removeItem(at: legacyURL)

View file

@ -15,5 +15,9 @@ extension UserDefaults {
static let defaultScriptName = "DefaultScriptName" static let defaultScriptName = "DefaultScriptName"
static let defaultScriptNameValue = "" static let defaultScriptNameValue = ""
static let targetDeviceIP = "TunnelDeviceIP" static let targetDeviceIP = "TunnelDeviceIP"
/// Attaches processes with MallocGuardEdges and MallocScribble.
static let mallocDebug = "enableMallocDebug"
static let mallocGuardEdges = "enableMallocGuardEdges"
static let mallocScribble = "enableMallocScribble"
} }
} }

View file

@ -266,7 +266,7 @@ struct ConsoleLogsView: View {
@ViewBuilder @ViewBuilder
private var exportMenuOption: some View { private var exportMenuOption: some View {
let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") let logURL = IdeviceLogFileManager.logURL
if FileManager.default.fileExists(atPath: logURL.path) { if FileManager.default.fileExists(atPath: logURL.path) {
ShareLink( ShareLink(
item: logURL, item: logURL,
@ -309,7 +309,7 @@ struct ConsoleLogsView: View {
guard !isLoadingLogs else { return } guard !isLoadingLogs else { return }
isLoadingLogs = true isLoadingLogs = true
let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path let logPath = IdeviceLogFileManager.logURL.path
guard FileManager.default.fileExists(atPath: logPath) else { guard FileManager.default.fileExists(atPath: logPath) else {
await MainActor.run { await MainActor.run {
@ -368,7 +368,7 @@ struct ConsoleLogsView: View {
guard !isLoadingLogs else { return } guard !isLoadingLogs else { return }
isLoadingLogs = true isLoadingLogs = true
let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path let logPath = IdeviceLogFileManager.logURL.path
let previousCount = lastProcessedLineCount let previousCount = lastProcessedLineCount
guard FileManager.default.fileExists(atPath: logPath) else { guard FileManager.default.fileExists(atPath: logPath) else {

View file

@ -12,17 +12,14 @@ struct HomeView: View {
@AppStorage("bundleID") private var bundleID: String = "" @AppStorage("bundleID") private var bundleID: String = ""
@AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true
@ObservedObject private var mounting = MountingProgress.shared
@State private var hasAppeared = false @State private var hasAppeared = false
@State private var pendingJITEnableConfiguration: JITEnableConfiguration? @State private var pendingJITEnableConfiguration: JITEnableConfiguration?
@State private var isShowingPairingFilePicker = false @State private var isShowingPairingFilePicker = false
@State private var debugFeedback: DebugFeedback? @State private var debugFeedback: DebugFeedback?
@State private var pendingExternalURLAction: HomeExternalAction? @State private var pendingExternalURLAction: HomeExternalAction?
@State private var pendingLiveContainerApp: LiveContainerApp?
@State private var scriptRunModel: RunJSViewModel? @State private var scriptRunModel: RunJSViewModel?
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private struct DebugFeedback: Identifiable { private struct DebugFeedback: Identifiable {
let id = UUID() let id = UUID()
let message: String let message: String
@ -30,11 +27,18 @@ struct HomeView: View {
let isWorking: Bool let isWorking: Bool
} }
private struct LiveContainerApp {
let bundleID: String
let name: String
}
var body: some View { var body: some View {
InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in
bundleID = selectedBundle if selectedName.localizedCaseInsensitiveContains("LiveContainer") {
Haptics.medium() pendingLiveContainerApp = LiveContainerApp(bundleID: selectedBundle, name: selectedName)
startJITInBackground(bundleID: selectedBundle, displayName: selectedName) } else {
enableJITForSelectedApp(bundleID: selectedBundle, name: selectedName)
}
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let debugFeedback { if let debugFeedback {
@ -45,9 +49,6 @@ struct HomeView: View {
} }
.onAppear(perform: handleAppear) .onAppear(perform: handleAppear)
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification) .onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification)
.onReceive(timer) { _ in
refreshMountStatusIfNeeded()
}
.onOpenURL { url in .onOpenURL { url in
handleExternalURL(url) handleExternalURL(url)
} }
@ -74,6 +75,28 @@ struct HomeView: View {
} message: { action in } message: { action in
Text(action.message) Text(action.message)
} }
.alert(
"Enable JIT for LiveContainer?",
isPresented: Binding(
get: { pendingLiveContainerApp != nil },
set: { isPresented in
if !isPresented {
pendingLiveContainerApp = nil
}
}
),
presenting: pendingLiveContainerApp
) { app in
Button("Cancel", role: .cancel) {
pendingLiveContainerApp = nil
}
Button("Proceed") {
pendingLiveContainerApp = nil
enableJITForSelectedApp(bundleID: app.bundleID, name: app.name)
}
} message: { _ in
Text("StikDebug isn't meant to enable JIT for LiveContainer manually. Select StikDebug in LiveContainer settings, enable \"Run with JIT\" for your app, then open your app normally. It will automatically jump back here.")
}
.fileImporter( .fileImporter(
isPresented: $isShowingPairingFilePicker, isPresented: $isShowingPairingFilePicker,
allowedContentTypes: PairingFileStore.supportedContentTypes, allowedContentTypes: PairingFileStore.supportedContentTypes,
@ -92,9 +115,14 @@ struct HomeView: View {
} }
} }
private func enableJITForSelectedApp(bundleID selectedBundle: String, name: String) {
bundleID = selectedBundle
Haptics.medium()
startJITInBackground(bundleID: selectedBundle, displayName: name)
}
private func handleAppear() { private func handleAppear() {
startTunnelInBackground() startTunnelInBackground()
MountingProgress.shared.checkforMounted()
hasAppeared = true hasAppeared = true
if let config = pendingJITEnableConfiguration { if let config = pendingJITEnableConfiguration {
@ -117,13 +145,6 @@ struct HomeView: View {
scriptRunModel = model scriptRunModel = model
} }
private func refreshMountStatusIfNeeded() {
guard mounting.mountingThread == nil, !mounting.coolisMounted else {
return
}
MountingProgress.shared.checkforMounted()
}
private func importPairingFile(_ result: Result<URL, Error>) { private func importPairingFile(_ result: Result<URL, Error>) {
switch result { switch result {
case .success(let url): case .success(let url):

View file

@ -83,7 +83,9 @@ struct InstalledAppsListView: View {
handleLoadingChange(isLoading) handleLoadingChange(isLoading)
} }
.onReceive(NotificationCenter.default.publisher(for: .pairingFileImported)) { _ in .onReceive(NotificationCenter.default.publisher(for: .pairingFileImported)) { _ in
viewModel.refreshAppLists() if TunnelManager.shared.isConnected {
viewModel.refreshAppLists()
}
} }
} }

View file

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Foundation import Foundation
import CoreLocation
private enum ExternalLocationAction: Identifiable { private enum ExternalLocationAction: Identifiable {
case simulate(URL, Double, Double) case simulate(URL, Double, Double)
@ -223,7 +224,22 @@ struct MainTabView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
if code == 0 { if code == 0 {
BackgroundLocationManager.shared.requestStart() LocationSimulationSession.shared.clearRoute()
LocationSimulationSession.shared.startResending(
at: CLLocationCoordinate2D(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
) {
LocationSimulationCommandQueue.shared.async {
_ = simulate_location(
DeviceConnectionContext.targetIPAddress,
coordinate.latitude,
coordinate.longitude,
pairingFile.path
)
}
}
LogManager.shared.addInfoLog( LogManager.shared.addInfoLog(
String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude) String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude)
) )
@ -243,7 +259,7 @@ struct MainTabView: View {
let code = clear_simulated_location() let code = clear_simulated_location()
DispatchQueue.main.async { DispatchQueue.main.async {
if code == 0 { if code == 0 {
BackgroundLocationManager.shared.requestStop() LocationSimulationSession.shared.stop()
LogManager.shared.addInfoLog("Cleared simulated location from URL") LogManager.shared.addInfoLog("Cleared simulated location from URL")
} else { } else {
showAlert( showAlert(

View file

@ -724,11 +724,11 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo
} }
struct LocationSimulationView: View { struct LocationSimulationView: View {
@ObservedObject private var simulationSession = LocationSimulationSession.shared
@State private var coordinate: CLLocationCoordinate2D? @State private var coordinate: CLLocationCoordinate2D?
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic) @State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
@State private var resendTimer: Timer?
@State private var routeLoadTask: Task<Void, Never>? @State private var routeLoadTask: Task<Void, Never>?
@State private var routeSpeedPrefetchTask: Task<Void, Never>? @State private var routeSpeedPrefetchTask: Task<Void, Never>?
@State private var routePlaybackTask: Task<Void, Never>? @State private var routePlaybackTask: Task<Void, Never>?
@ -788,11 +788,11 @@ struct LocationSimulationView: View {
} }
private var hasActiveSimulation: Bool { private var hasActiveSimulation: Bool {
simulatedCoordinate != nil || routePlaybackTask != nil simulationSession.isActive || simulatedCoordinate != nil || routePlaybackTask != nil
} }
private var isRouteRunning: Bool { private var isRouteRunning: Bool {
routePlaybackTask != nil routePlaybackTask != nil || simulationSession.routeCoordinates != nil
} }
private var hasRouteContext: Bool { private var hasRouteContext: Bool {
@ -801,7 +801,12 @@ struct LocationSimulationView: View {
routePlan != nil || routePlan != nil ||
isLoadingRoute || isLoadingRoute ||
isPrefetchingRouteSpeeds || isPrefetchingRouteSpeeds ||
routePlaybackCoordinate != nil routePlaybackCoordinate != nil ||
simulationSession.routeCoordinates != nil
}
private var displayedRouteCoordinate: CLLocationCoordinate2D? {
simulationSession.routeCoordinates == nil ? routePlaybackCoordinate : simulationSession.coordinate
} }
private var routeSummaryText: String? { private var routeSummaryText: String? {
@ -818,6 +823,9 @@ struct LocationSimulationView: View {
} }
private var routeStatusText: String { private var routeStatusText: String {
if simulationSession.routeCoordinates != nil {
return "Route simulation active."
}
if isLoadingRoute { if isLoadingRoute {
return "Calculating route…" return "Calculating route…"
} }
@ -892,8 +900,8 @@ struct LocationSimulationView: View {
Marker("End", coordinate: routeEndCoordinate) Marker("End", coordinate: routeEndCoordinate)
.tint(.red) .tint(.red)
} }
if let routePlaybackCoordinate { if let displayedRouteCoordinate {
Marker("Current", coordinate: routePlaybackCoordinate) Marker("Current", coordinate: displayedRouteCoordinate)
.tint(.blue) .tint(.blue)
} }
} else if let coordinate { } else if let coordinate {
@ -1025,17 +1033,28 @@ struct LocationSimulationView: View {
} }
.onAppear { .onAppear {
loadBookmarks() loadBookmarks()
if let routeCoordinates = simulationSession.routeCoordinates {
coordinate = nil
routePolyline = makeRoutePolyline(for: routeCoordinates)
routeStartSelection = routeCoordinates.first.map {
RouteSearchSelection(title: "Route Start", coordinate: $0)
}
routeEndSelection = routeCoordinates.last.map {
RouteSearchSelection(title: "Route End", coordinate: $0)
}
if let routePolyline {
position = .rect(routePolyline.boundingMapRect)
}
} else if let activeCoordinate = simulationSession.coordinate {
coordinate = activeCoordinate
simulatedCoordinate = activeCoordinate
}
} }
.onDisappear { .onDisappear {
routeLoadTask?.cancel() routeLoadTask?.cancel()
routeLoadTask = nil routeLoadTask = nil
routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask?.cancel()
routeSpeedPrefetchTask = nil routeSpeedPrefetchTask = nil
cancelRoutePlayback(resetMarker: true)
stopResendLoop()
if backgroundTaskID != .invalid {
BackgroundLocationManager.shared.requestStop()
}
endBackgroundTask() endBackgroundTask()
} }
} }
@ -1297,14 +1316,14 @@ struct LocationSimulationView: View {
) { ) {
routePlaybackCoordinate = nil routePlaybackCoordinate = nil
beginBackgroundTask() beginBackgroundTask()
LocationSimulationSession.shared.clearRoute()
startResendLoop(with: coord) startResendLoop(with: coord)
BackgroundLocationManager.shared.requestStart()
} }
} }
private func simulateRoute() { private func simulateRoute() {
guard pairingExists, guard pairingExists,
routePlan != nil, let routePlan,
let firstCoordinate = routePlaybackSamples.first?.coordinate, let firstCoordinate = routePlaybackSamples.first?.coordinate,
!isBusy else { !isBusy else {
return return
@ -1319,10 +1338,13 @@ struct LocationSimulationView: View {
operation: { locationUpdateCode(for: firstCoordinate) } operation: { locationUpdateCode(for: firstCoordinate) }
) { ) {
beginBackgroundTask() beginBackgroundTask()
BackgroundLocationManager.shared.requestStart() let routeID = LocationSimulationSession.shared.startRoute(
at: firstCoordinate,
coordinates: routePlan.displayCoordinates
)
simulatedCoordinate = nil simulatedCoordinate = nil
routePlaybackCoordinate = firstCoordinate routePlaybackCoordinate = firstCoordinate
startRoutePlayback() startRoutePlayback(routeID: routeID)
} }
} }
@ -1356,13 +1378,14 @@ struct LocationSimulationView: View {
routeSpeedPrefetchTask = nil routeSpeedPrefetchTask = nil
cancelRoutePlayback(resetMarker: true) cancelRoutePlayback(resetMarker: true)
stopResendLoop() stopResendLoop()
LocationSimulationSession.shared.clearRoute()
runLocationCommand( runLocationCommand(
errorTitle: "Clear Failed", errorTitle: "Clear Failed",
errorMessage: { code in "Could not clear simulated location (error \(code))." }, errorMessage: { code in "Could not clear simulated location (error \(code))." },
operation: clear_simulated_location operation: clear_simulated_location
) { ) {
endBackgroundTask() endBackgroundTask()
BackgroundLocationManager.shared.requestStop() LocationSimulationSession.shared.stop()
} }
} }
@ -1379,18 +1402,15 @@ struct LocationSimulationView: View {
private func startResendLoop(with coordinate: CLLocationCoordinate2D) { private func startResendLoop(with coordinate: CLLocationCoordinate2D) {
simulatedCoordinate = coordinate simulatedCoordinate = coordinate
resendTimer?.invalidate() LocationSimulationSession.shared.startResending(at: coordinate) {
resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
guard let simulatedCoordinate else { return }
LocationSimulationCommandQueue.shared.async { LocationSimulationCommandQueue.shared.async {
_ = locationUpdateCode(for: simulatedCoordinate) _ = locationUpdateCode(for: coordinate)
} }
} }
} }
private func stopResendLoop() { private func stopResendLoop() {
resendTimer?.invalidate() LocationSimulationSession.shared.pauseResending()
resendTimer = nil
simulatedCoordinate = nil simulatedCoordinate = nil
} }
@ -1520,15 +1540,19 @@ struct LocationSimulationView: View {
} }
} }
private func startRoutePlayback() { private func startRoutePlayback(routeID: UUID) {
routePlaybackTask = Task { routePlaybackTask = Task {
var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate
for sample in routePlaybackSamples.dropFirst() { for sample in routePlaybackSamples.dropFirst() {
try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) try? await Task.sleep(for: .seconds(sample.delayFromPrevious))
guard !Task.isCancelled else { return } guard !Task.isCancelled,
LocationSimulationSession.shared.isCurrentRoute(routeID) else {
return
}
let code = await sendLocationUpdate(for: sample.coordinate) let code = await sendLocationUpdate(for: sample.coordinate)
guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return }
guard code == 0 else { guard code == 0 else {
await MainActor.run { await MainActor.run {
routePlaybackTask = nil routePlaybackTask = nil
@ -1546,10 +1570,12 @@ struct LocationSimulationView: View {
lastSuccessfulCoordinate = sample.coordinate lastSuccessfulCoordinate = sample.coordinate
await MainActor.run { await MainActor.run {
routePlaybackCoordinate = sample.coordinate routePlaybackCoordinate = sample.coordinate
LocationSimulationSession.shared.updateCoordinate(sample.coordinate)
} }
} }
await MainActor.run { await MainActor.run {
guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return }
routePlaybackTask = nil routePlaybackTask = nil
if let lastSuccessfulCoordinate { if let lastSuccessfulCoordinate {
routePlaybackCoordinate = lastSuccessfulCoordinate routePlaybackCoordinate = lastSuccessfulCoordinate

View file

@ -7,8 +7,8 @@ import SwiftUI
import UIKit import UIKit
private enum SettingsLinks { private enum SettingsLinks {
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")! static let githubStars = URL(string: "https://github.com/StikDebug/StikDebug")!
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")! static let pairingFileGuide = URL(string: "https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md")!
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")! static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")! static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
} }
@ -19,6 +19,7 @@ struct SettingsView: View {
@AppStorage("keepAliveAudio") private var keepAliveAudio = true @AppStorage("keepAliveAudio") private var keepAliveAudio = true
@AppStorage("keepAliveLocation") private var keepAliveLocation = true @AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress @AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress
@AppStorage(UserDefaults.Keys.mallocDebug) private var mallocDebug = false
@State private var isShowingPairingFilePicker = false @State private var isShowingPairingFilePicker = false
@State private var isImportingFile = false @State private var isImportingFile = false
@ -94,8 +95,7 @@ struct SettingsView: View {
} }
} }
.onChange(of: keepAliveAudio) { _, enabled in .onChange(of: keepAliveAudio) { _, enabled in
if enabled { BackgroundAudioManager.shared.start() } handleAudioKeepAliveChange(enabled)
else { BackgroundAudioManager.shared.stop() }
} }
Toggle(isOn: $keepAliveLocation) { Toggle(isOn: $keepAliveLocation) {
@ -106,7 +106,7 @@ struct SettingsView: View {
} }
} }
.onChange(of: keepAliveLocation) { _, enabled in .onChange(of: keepAliveLocation) { _, enabled in
if !enabled { BackgroundLocationManager.shared.stop() } handleLocationKeepAliveChange(enabled)
} }
} header: { } header: {
@ -131,6 +131,16 @@ struct SettingsView: View {
} }
} }
Section("Debugging") {
Toggle(isOn: $mallocDebug) {
VStack(alignment: .leading, spacing: 2) {
Text("Malloc Debugging")
Text("Attaches processes with MallocGuardEdges and MallocScribble to detect memory corruption.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
Section("Advanced") { Section("Advanced") {
HStack { HStack {
Text("Target Device IP") Text("Target Device IP")
@ -231,6 +241,25 @@ struct SettingsView: View {
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)" return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
} }
private func handleAudioKeepAliveChange(_ enabled: Bool) {
if !enabled, !keepAliveLocation {
keepAliveLocation = true
BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
}
BackgroundAudioManager.shared.configurationDidChange()
BackgroundLocationManager.shared.configurationDidChange()
}
private func handleLocationKeepAliveChange(_ enabled: Bool) {
if enabled {
BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
} else if !keepAliveAudio {
keepAliveAudio = true
}
BackgroundAudioManager.shared.configurationDidChange()
BackgroundLocationManager.shared.configurationDidChange()
}
// MARK: - Business Logic // MARK: - Business Logic
private func openAppFolder() { private func openAppFolder() {

File diff suppressed because it is too large Load diff

Binary file not shown.