Compare commits

..

62 commits
3.1.6 ... 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
CelloSerenity
49b7c7aef4 Update Website-Stuff source on release 2026-07-31 20:40:19 -06:00
CelloSerenity
809e7a82af
Update project.pbxproj 2026-07-31 13:23:00 -06:00
CelloSerenity
ff57409541
Merge branch 'StephenDev0:main' into main 2026-07-31 08:36:00 -06:00
CelloSerenity
dce4c2edfd Bounce back before script execution 2026-07-31 08:27:31 -06:00
CelloSerenity
60accbe1d1 Reuse open remote server for JIT bounce-back to avoid cold-launch race 2026-07-31 08:27:31 -06:00
CelloSerenity
83da8aad29 Make pairing file import atomic 2026-07-31 08:27:16 -06:00
858921019d
Add 'TachyonU' to the list of script assignments 2026-07-31 07:57:13 -04:00
CelloSerenity
c1189928d4 Fix JIT race on cold URL-scheme launch 2026-07-26 00:34:07 -06:00
CelloSerenity
03b7c109ac Rename UTM-Dolphin.js to legacy.js and drop screenshot demos 2026-07-25 18:21:34 -06:00
CelloSerenity
2047bcbe85
3.1.7 2026-07-25 18:15:54 -06:00
CelloSerenity
8b63ec2840 Update build_ipa.yml 2026-07-25 13:17:30 -06:00
CelloSerenity
5e0e48d4e6 Return to the target app after attaching for JIT 2026-07-25 13:17:25 -06:00
Stephen
c5b23e2282 Fix my mistake 2026-06-18 13:27:49 -04:00
CelloSerenity
1bb753086f
Update readme 2026-06-16 22:12:39 -06:00
34 changed files with 4747 additions and 514 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' }}
@ -24,7 +27,7 @@ jobs:
- name: 2. Configure Xcode Environment - name: 2. Configure Xcode Environment
uses: maxim-lobanov/setup-xcode@v1 uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: '26.0.1' xcode-version: '26.6'
- name: 3. Compile Project Archive - name: 3. Compile Project Archive
run: | run: |
@ -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' if: env.UPLOAD_IPA == 'true'
uses: actions/upload-artifact@v4 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'
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,104 +0,0 @@
name: Update StikJIT Source
on:
release:
types: [published]
workflow_dispatch:
# Add permissions block to ensure the workflow can push changes
permissions:
contents: write
jobs:
update-source:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: 0-Blu/StikJIT
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Record job start time
id: job_start_time
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
- name: Debug repository information
run: |
echo "Repository: 0-Blu/StikJIT"
echo "Event name: $GITHUB_EVENT_NAME"
echo "Current directory: $(pwd)"
echo "Repository contents:"
ls -la
- name: Update StikJIT source
id: update_source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure git
git config --global user.name 'GitHub Action'
git config --global user.email 'action@github.com'
# Run the update script
python update_json.py
# Check for changes and commit if needed
git add repo.json
if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes detected in repo.json"
else
echo "Changes detected in repo.json:"
git diff --staged
git commit -m "Updated source with latest StikJIT release"
# Push to main branch of original repository
git push origin main
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Calculate job duration
id: duration
if: always()
run: |
end_time=$(date +%s)
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
- name: Create job summary
run: |
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
echo "## Update StikJIT Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "✅ Changes Detected and Pushed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The repo.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
else
echo "## Update StikJIT Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The repo.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This could be because:" >> $GITHUB_STEP_SUMMARY
echo "- The release version is already in the repo.json file" >> $GITHUB_STEP_SUMMARY
echo "- The release doesn't contain an IPA file" >> $GITHUB_STEP_SUMMARY
echo "- The release tag doesn't match the expected version format" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The next scheduled run will be triggered when a new release is published." >> $GITHUB_STEP_SUMMARY

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.5/StikDebug-3.1.5.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.6; 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.6; 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

@ -45,6 +45,7 @@ struct StikDebugApp: App {
private func downloadMissingDeveloperDiskImageFiles() async { private func downloadMissingDeveloperDiskImageFiles() async {
do { do {
try await DeveloperDiskImageService.shared.downloadMissingFiles() try await DeveloperDiskImageService.shared.downloadMissingFiles()
MountingProgress.shared.pubMount()
} catch { } catch {
await MainActor.run { await MainActor.run {
showAlert( showAlert(

View file

@ -11,6 +11,12 @@ enum DeviceConnectionContext {
static let defaultTargetIPAddress = "10.7.0.1" static let defaultTargetIPAddress = "10.7.0.1"
static var targetIPAddress: String { static var targetIPAddress: String {
defaultTargetIPAddress let stored = UserDefaults.standard
.string(forKey: UserDefaults.Keys.targetDeviceIP)?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stored, !stored.isEmpty else {
return defaultTargetIPAddress
}
return stored
} }
} }

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 {
@ -642,6 +671,46 @@ final class JITEnableContext {
} }
} }
func relaunchApp(_ bundleID: String, logger: LogFunc? = nil) -> Bool {
do {
let pid = try withConnectedRemoteServer { remoteServer in
try relaunchApp(bundleID, remoteServer: remoteServer)
}
emitLog("Returned to \(bundleID) (PID \(pid))", logger: logger)
return true
} catch {
emitLog("Failed to return to \(bundleID): \(error.localizedDescription)", logger: logger)
return false
}
}
func relaunchApp(_ bundleID: String, usingRemoteServer remoteServer: OpaquePointer, logger: LogFunc? = nil) -> Bool {
do {
let pid = try relaunchApp(bundleID, remoteServer: remoteServer)
emitLog("Returned to \(bundleID) (PID \(pid))", logger: logger)
return true
} catch {
emitLog("Failed to return to \(bundleID): \(error.localizedDescription)", logger: logger)
return false
}
}
private func relaunchApp(_ bundleID: String, remoteServer: OpaquePointer) throws -> UInt64 {
try withProcessControl(remoteServer: remoteServer) { processControl in
var pid: UInt64 = 0
let ffiError = bundleID.withCString { bundleID in
process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, false, false, &pid)
}
if let ffiError {
throw error(from: ffiError, fallback: "Failed to return to app")
}
return pid
}
}
func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) {
do { do {
try ensureTunnel() try ensureTunnel()

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

@ -22,12 +22,17 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
var debugProxy: OpaquePointer? var debugProxy: OpaquePointer?
var remoteServer: OpaquePointer? var remoteServer: OpaquePointer?
var semaphore: DispatchSemaphore? var semaphore: DispatchSemaphore?
let resumeBundleID: String?
init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?) {
private let resumeLock = NSLock()
private var hasResumedApp = false
init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?, resumeBundleID: String? = nil) {
self.pid = pid self.pid = pid
self.debugProxy = debugProxy self.debugProxy = debugProxy
self.remoteServer = remoteServer self.remoteServer = remoteServer
self.semaphore = semaphore self.semaphore = semaphore
self.resumeBundleID = resumeBundleID
} }
func runScript(path: URL, scriptName: String? = nil) throws { func runScript(path: URL, scriptName: String? = nil) throws {
@ -40,6 +45,8 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
self.scriptName = displayName self.scriptName = displayName
} }
_ = resumeTargetApp()
guard let scriptContent = String(data: data, encoding: .utf8) else { guard let scriptContent = String(data: data, encoding: .utf8) else {
throw CocoaError(.fileReadCorruptFile) throw CocoaError(.fileReadCorruptFile)
} }
@ -57,8 +64,11 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
self.context?.exception = JSValue(object: "Script execution is interrupted by StikDebug.", in: self.context!) self.context?.exception = JSValue(object: "Script execution is interrupted by StikDebug.", in: self.context!)
return "" return ""
} }
return handleJSContextSendDebugCommand(self.context, commandStr, self.debugProxy) ?? "" let response = handleJSContextSendDebugCommand(self.context, commandStr, self.debugProxy) ?? ""
return response
} }
let logFunction: @convention(block) (String) -> Void = { logStr in let logFunction: @convention(block) (String) -> Void = { logStr in
@ -78,8 +88,13 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
let hasTXMFunction: @convention(block) () -> Bool = { let hasTXMFunction: @convention(block) () -> Bool = {
return ProcessInfo.processInfo.hasTXM return ProcessInfo.processInfo.hasTXM
} }
let resumeAppFunction: @convention(block) () -> String = {
return self.resumeTargetApp()
}
context = JSContext() context = JSContext()
context?.setObject(resumeAppFunction, forKeyedSubscript: "resume_app" as NSString)
context?.setObject(hasTXMFunction, forKeyedSubscript: "hasTXM" as NSString) context?.setObject(hasTXMFunction, forKeyedSubscript: "hasTXM" as NSString)
context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString) context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString)
context?.setObject(sendCommandFunction, forKeyedSubscript: "send_command" as NSString) context?.setObject(sendCommandFunction, forKeyedSubscript: "send_command" as NSString)
@ -101,6 +116,40 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
} }
} }
@discardableResult
private func resumeTargetApp() -> String {
resumeLock.lock()
if hasResumedApp {
resumeLock.unlock()
return "OK"
}
guard let resumeBundleID else {
resumeLock.unlock()
return "no app to return to for this session"
}
hasResumedApp = true
resumeLock.unlock()
appendLog("Returning to \(resumeBundleID)...")
let success: Bool
if let remoteServer {
success = JITEnableContext.shared.relaunchApp(resumeBundleID, usingRemoteServer: remoteServer)
} else {
success = JITEnableContext.shared.relaunchApp(resumeBundleID)
}
guard success else {
appendLog("Failed to return to \(resumeBundleID) — continuing in the foreground.")
return "failed to return to \(resumeBundleID)"
}
return "OK"
}
private func appendLog(_ message: String) {
DispatchQueue.main.async {
self.logs.append(message)
}
}
private func captureScreenshot(named preferredName: String?) -> String { private func captureScreenshot(named preferredName: String?) -> String {
if executionInterrupted { if executionInterrupted {
raiseException("Script execution is interrupted by StikDebug.") raiseException("Script execution is interrupted by StikDebug.")

View file

@ -201,21 +201,7 @@ struct ScriptListView: View {
// MARK: - File Ops // MARK: - File Ops
private func scriptsDirectory() throws -> URL { private func scriptsDirectory() throws -> URL {
let directory = try ScriptStore.prepareDirectory() try ScriptStore.prepareDirectory()
try ensureEditorScripts(in: directory)
return directory
}
private func ensureEditorScripts(in directory: URL) throws {
let fm = FileManager.default
let screenshotURL = directory.appendingPathComponent("screenshot-demo.js")
if !fm.fileExists(atPath: screenshotURL.path) {
try screenshotDemoScript.write(to: screenshotURL, atomically: true, encoding: .utf8)
}
let standaloneURL = directory.appendingPathComponent("screenshot-capture.js")
if !fm.fileExists(atPath: standaloneURL.path) {
try screenshotCaptureScript.write(to: standaloneURL, atomically: true, encoding: .utf8)
}
} }
private func loadScripts() { private func loadScripts() {
@ -331,58 +317,3 @@ struct ScriptListView: View {
} }
} }
} }
// MARK: - Script content stubs
private let screenshotDemoScript = """
// Screenshot Demo Script
// Attaches to the target, captures a PNG screenshot, and detaches.
function takeScreenshotDemo() {
log("[ScreenshotDemo] Starting demo");
const pid = get_pid();
log(`[ScreenshotDemo] Target PID: ${pid}`);
const attachResponse = send_command(`vAttach;${pid.toString(16)}`);
log(`[ScreenshotDemo] attach_response = ${attachResponse}`);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `screenshot-${timestamp}.png`;
const savedPath = take_screenshot(fileName);
if (savedPath && savedPath.length > 0) {
log(`[ScreenshotDemo] Screenshot saved to ${savedPath}`);
} else {
log("[ScreenshotDemo] Device did not report a saved path.");
}
const detachResponse = send_command("D");
log(`[ScreenshotDemo] detach_response = ${detachResponse}`);
log("[ScreenshotDemo] Demo complete.");
}
takeScreenshotDemo();
"""
private let screenshotCaptureScript = """
// Screenshot Capture Script
// Takes a screenshot without sending any debugserver commands.
function captureScreenshot() {
log("[ScreenshotCapture] Requesting screenshot without attaching…");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `standalone-${timestamp}.png`;
const savedPath = take_screenshot(fileName);
if (savedPath && savedPath.length > 0) {
log(`[ScreenshotCapture] Screenshot saved to ${savedPath}`);
} else {
log("[ScreenshotCapture] Device did not report a saved path.");
}
log("[ScreenshotCapture] Done.");
}
captureScreenshot();
"""

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()
} }
@ -103,16 +98,11 @@ final class TunnelManager: ObservableObject {
} }
private func handleInvalidPairingFile() { private func handleInvalidPairingFile() {
do { LogManager.shared.addInfoLog("Pairing file reported invalid; keeping existing file")
try PairingFileStore.remove()
LogManager.shared.addInfoLog("Removed invalid pairing file")
} catch {
LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)")
}
showAlert( showAlert(
title: "Invalid Pairing File", title: "Invalid Pairing File",
message: "The pairing file is invalid or expired. Please select a new pairing file.", message: "The pairing file may be invalid or expired. You can import a new pairing file to replace it.",
showOk: true, showOk: true,
showTryAgain: false, showTryAgain: false,
primaryButtonText: "Select New File" primaryButtonText: "Select New File"

View file

@ -14,12 +14,17 @@ extension ScriptStore {
appNames: [ appNames: [
"Amethyst", "Amethyst",
"MeloNX", "MeloNX",
"Melo",
"XeniOS", "XeniOS",
"MeloCafe", "MeloCafé",
"Manic EMU", "Manic EMU",
"Manic",
"DukeX", "DukeX",
"TachyonU",
"touchHLE",
"HyperHLE",
"Applesauce",
"RPCS3",
"AetherPS4",
"Husk"
], ],
resource: ScriptResource(resourceName: "universal", fileName: "universal.js") resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
), ),
@ -36,7 +41,7 @@ extension ScriptStore {
"Flycast", "Flycast",
"ARMSX2 iOS" "ARMSX2 iOS"
], ],
resource: ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js") resource: ScriptResource(resourceName: "legacy", fileName: "legacy.js")
), ),
AutoScriptAssignment( AutoScriptAssignment(
appNames: [ appNames: [

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,24 +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)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination) let documentsDestination = documentsURL
if sourceURL.standardizedFileURL != documentsDestination.standardizedFileURL {
try replaceItem(at: documentsDestination, with: sourceURL, fileManager: fileManager)
} }
removeLegacyCopies(fileManager: fileManager) try replaceItem(at: url, with: documentsDestination, fileManager: fileManager)
try fileManager.copyItem(at: sourceURL, to: destination) protectPairingFile(at: url, fileManager: fileManager)
protectPairingFile(at: destination, fileManager: fileManager)
} }
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
@ -76,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)
} }
} }
@ -97,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

@ -20,7 +20,7 @@ enum ScriptStore {
ScriptResource(resourceName: "maciOS", fileName: "maciOS.js"), ScriptResource(resourceName: "maciOS", fileName: "maciOS.js"),
ScriptResource(resourceName: "universal", fileName: "universal.js"), ScriptResource(resourceName: "universal", fileName: "universal.js"),
ScriptResource(resourceName: "Geode", fileName: "Geode.js"), ScriptResource(resourceName: "Geode", fileName: "Geode.js"),
ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js") ScriptResource(resourceName: "legacy", fileName: "legacy.js")
] ]
static var directoryURL: URL { static var directoryURL: URL {
@ -54,6 +54,16 @@ enum ScriptStore {
return directory.appendingPathComponent(scriptName) return directory.appendingPathComponent(scriptName)
} }
static func script(named scriptName: String, fileManager: FileManager = .default) -> (data: Data, name: String)? {
guard let normalizedName = normalizedScriptFileName(scriptName),
let scriptURL = try? scriptURL(named: normalizedName, fileManager: fileManager),
let data = try? Data(contentsOf: scriptURL) else {
return nil
}
return (data, normalizedName)
}
static func assignedScriptName(for bundleID: String, defaults: UserDefaults = .standard) -> String? { static func assignedScriptName(for bundleID: String, defaults: UserDefaults = .standard) -> String? {
assignedScriptMap(defaults: defaults)[bundleID] assignedScriptMap(defaults: defaults)[bundleID]
} }

View file

@ -14,5 +14,10 @@ extension UserDefaults {
static let bundleScriptMap = "BundleScriptMap" static let bundleScriptMap = "BundleScriptMap"
static let defaultScriptName = "DefaultScriptName" static let defaultScriptName = "DefaultScriptName"
static let defaultScriptNameValue = "" static let defaultScriptNameValue = ""
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):
@ -175,6 +196,14 @@ struct HomeView: View {
} }
if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) { if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) {
config.scriptName = scriptName config.scriptName = scriptName
if config.scriptData == nil {
if let namedScript = ScriptStore.script(named: scriptName) {
config.scriptData = namedScript.data
config.scriptName = namedScript.name
} else {
LogManager.shared.addWarningLog("Script \(scriptName) was not found in the scripts folder")
}
}
} }
if config.scriptData == nil, let bundleID = config.bundleID, if config.scriptData == nil, let bundleID = config.bundleID,
let scriptInfo = ScriptStore.preferredScript(for: bundleID) { let scriptInfo = ScriptStore.preferredScript(for: bundleID) {
@ -259,12 +288,13 @@ struct HomeView: View {
.accessibilityLabel(feedback.message) .accessibilityLabel(feedback.message)
} }
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { private func getJsCallback(_ script: Data, name: String? = nil, resumeBundleID: String? = nil) -> DebugAppCallback {
return { pid, debugProxyHandle, remoteServerHandle, semaphore in return { pid, debugProxyHandle, remoteServerHandle, semaphore in
let model = RunJSViewModel(pid: Int(pid), let model = RunJSViewModel(pid: Int(pid),
debugProxy: debugProxyHandle, debugProxy: debugProxyHandle,
remoteServer: remoteServerHandle, remoteServer: remoteServerHandle,
semaphore: semaphore) semaphore: semaphore,
resumeBundleID: resumeBundleID)
DispatchQueue.main.async { DispatchQueue.main.async {
scriptRunModel = model scriptRunModel = model
@ -299,8 +329,18 @@ struct HomeView: View {
let keepAliveLease = DebugKeepAliveLease() let keepAliveLease = DebugKeepAliveLease()
defer { keepAliveLease.invalidate() } defer { keepAliveLease.invalidate() }
if triggeredByURLScheme { if triggeredByURLScheme, !waitForJITPrerequisites() {
sleep(1) DispatchQueue.main.async {
withAnimation {
debugFeedback = nil
}
showAlert(
title: "Failed to Enable JIT".localized,
message: "The device connection or Developer Disk Image wasn't ready in time. Open StikDebug directly, wait for it to finish connecting, then try again.".localized,
showOk: true
)
}
return
} }
let finishProcessing: (Bool, String?) -> Void = { success, detail in let finishProcessing: (Bool, String?) -> Void = { success, detail in
@ -338,9 +378,11 @@ struct HomeView: View {
scriptData = preferred.data scriptData = preferred.data
} }
let resumeBundleID = pid == nil ? nil : bundleID
var callback: DebugAppCallback? = nil var callback: DebugAppCallback? = nil
if ProcessInfo.processInfo.hasTXM, let sd = scriptData { if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script", resumeBundleID: resumeBundleID)
} }
var lastDebugMessage: String? var lastDebugMessage: String?
@ -373,6 +415,17 @@ struct HomeView: View {
} }
} }
private func waitForJITPrerequisites(timeout: TimeInterval = 20) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted {
return true
}
usleep(250_000)
}
return TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted
}
private func base64URLToBase64(_ base64url: String) -> String { private func base64URLToBase64(_ base64url: String) -> String {
var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let pad = 4 - (base64.count % 4) let pad = 4 - (base64.count % 4)

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")!
} }
@ -18,6 +18,8 @@ struct SettingsView: View {
@AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true
@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.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
@ -93,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) {
@ -105,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: {
@ -130,12 +131,27 @@ 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")
Spacer() Spacer()
Text(DeviceConnectionContext.defaultTargetIPAddress) TextField(DeviceConnectionContext.defaultTargetIPAddress, text: $targetDeviceIP)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.numbersAndPunctuation)
.frame(maxWidth: 160)
} }
Button { openAppFolder() } label: { Button { openAppFolder() } label: {
Label("App Folder", systemImage: "folder") Label("App Folder", systemImage: "folder")
@ -225,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.