mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1256c52803 | ||
|
|
3422fc4725 | ||
|
|
734361bb9d | ||
|
|
cd5e609387 | ||
|
|
e4665d74b5 | ||
|
|
300ebff50b | ||
|
|
775fba15f3 | ||
|
|
871f1461d9 | ||
|
|
4bdfc92aa7 | ||
|
|
4b3c8d6e88 | ||
| c03abcaa44 | |||
|
|
d4306e724b | ||
|
|
50766f5ac5 | ||
|
|
e7ef9491e2 | ||
|
|
6a078be17e | ||
|
|
2d09a405de | ||
|
|
f50353c643 | ||
|
|
cbaf467b66 | ||
|
|
534b6daa3f | ||
|
|
5e3e1bc91e | ||
|
|
3abf9d5b19 | ||
|
|
e85e0b3d47 | ||
|
|
f76672838b | ||
|
|
94bc9e8cf3 | ||
|
|
c5bad70d37 | ||
|
|
b916fe51cc | ||
|
|
b3e3096b63 | ||
|
|
8f0439cd0c | ||
|
|
460fb235c2 | ||
|
|
f5d31fe2b7 | ||
|
|
70d31322e7 | ||
|
|
66e359c7e2 | ||
|
|
ae460a7a5d | ||
|
|
8fdfb193e0 | ||
|
|
2354795117 | ||
|
|
a8a4892a8c | ||
|
|
cf4108fc2c | ||
| 30cfa58785 | |||
| 15d99027c0 | |||
| 38c0ca1348 | |||
| e160c9e90a | |||
|
|
f928790e7d | ||
|
|
30a147aaaf | ||
|
|
c7e08e64fd | ||
|
|
85fb1350a4 | ||
|
|
ff030b809a | ||
|
|
22e34051ff | ||
|
|
ea42d847d1 |
28 changed files with 4581 additions and 375 deletions
22
.github/scripts/update_source.sh
vendored
Executable file
22
.github/scripts/update_source.sh
vendored
Executable 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
|
||||||
71
.github/workflows/build_ipa.yml
vendored
71
.github/workflows/build_ipa.yml
vendored
|
|
@ -3,17 +3,20 @@ name: Build Debug IPA
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
tags: [ "[0-9]*" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build IPA
|
name: Build IPA
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
env:
|
env:
|
||||||
UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'true' }}
|
UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'true' }}
|
||||||
|
|
||||||
|
|
@ -50,10 +53,70 @@ jobs:
|
||||||
zip -r StikDebug.ipa Payload
|
zip -r StikDebug.ipa Payload
|
||||||
rm -rf Payload StikDebug.app
|
rm -rf Payload StikDebug.app
|
||||||
|
|
||||||
- name: 5. Store IPA as Workflow Artifact
|
- name: 5. Name workflow artifact
|
||||||
|
id: artifact
|
||||||
|
if: env.UPLOAD_IPA == 'true'
|
||||||
|
run: |
|
||||||
|
file_name="StikDebug-${GITHUB_SHA::7}.ipa"
|
||||||
|
cp StikDebug.ipa "$file_name"
|
||||||
|
echo "file_name=$file_name" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 6. Store IPA as Workflow Artifact
|
||||||
if: env.UPLOAD_IPA == 'true'
|
if: env.UPLOAD_IPA == 'true'
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: StikDebug-Debug.ipa
|
path: ${{ steps.artifact.outputs.file_name }}
|
||||||
path: StikDebug.ipa
|
archive: false
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
id: version
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
version=$(xcodebuild -project StikDebug.xcodeproj -scheme StikDebug -showBuildSettings | awk -F ' = ' '/ MARKETING_VERSION = / { print $2; exit }')
|
||||||
|
test -n "$version"
|
||||||
|
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
|
||||||
|
test "${GITHUB_REF_NAME#v}" = "$version"
|
||||||
|
fi
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Name release asset
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: mv StikDebug.ipa StikDebug-${{ steps.version.outputs.version }}.ipa
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.version.outputs.version }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
files: StikDebug-${{ steps.version.outputs.version }}.ipa
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
|
||||||
|
update-source:
|
||||||
|
name: Update website source
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Checkout website
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
repository: StikDebug/Website
|
||||||
|
ref: main
|
||||||
|
path: Website
|
||||||
|
token: ${{ secrets.WEBSITE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
|
||||||
|
- name: Update StikDebug source
|
||||||
|
run: .github/scripts/update_source.sh Website '${{ needs.build.outputs.version }}'
|
||||||
|
|
|
||||||
52
.github/workflows/updatesource.yml
vendored
52
.github/workflows/updatesource.yml
vendored
|
|
@ -1,52 +0,0 @@
|
||||||
name: Update StikDebug Source
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-source:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Website-Stuff
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
repository: StephenDev0/Website-Stuff
|
|
||||||
ref: main
|
|
||||||
path: Website-Stuff
|
|
||||||
# A repository-scoped GITHUB_TOKEN cannot push to Website-Stuff.
|
|
||||||
token: ${{ secrets.WEBSITE_STUFF_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.x'
|
|
||||||
|
|
||||||
- name: Update StikDebug source
|
|
||||||
id: update_source
|
|
||||||
working-directory: Website-Stuff
|
|
||||||
run: |
|
|
||||||
git config --global user.name 'GitHub Action'
|
|
||||||
git config --global user.email 'action@github.com'
|
|
||||||
|
|
||||||
python updatesource.py index.json
|
|
||||||
git add index.json
|
|
||||||
if git diff --staged --quiet; then
|
|
||||||
echo "changes=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "No changes detected in index.json"
|
|
||||||
else
|
|
||||||
git commit -m "Update StikDebug source for latest release"
|
|
||||||
git push origin main
|
|
||||||
echo "changes=true" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create job summary
|
|
||||||
run: |
|
|
||||||
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
|
|
||||||
echo "## StikDebug source updated" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Website-Stuff/index.json was updated and pushed." >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "## StikDebug source already current" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "No change to Website-Stuff/index.json was needed." >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
54
README.md
54
README.md
|
|
@ -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>
|
||||||
<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>
|
||||||
</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>
|
||||||
<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>
|
||||||
<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">
|
|
||||||
</a>
|
<a href="https://github.com/StikDebug/StikDebug/releases/latest/download/StikDebug-3.1.15.ipa" target="_blank"><img src="https://altdirect.app/assets/png/Download_Blue.png" alt="Download .ipa" width="200"></a>
|
||||||
<a href="https://github.com/StephenDev0/StikDebug/releases/download/3.1.6/StikDebug-3.1.6.ipa" target="_blank">
|
</h3>
|
||||||
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/Download_Blue.png" alt="Download .ipa" width="200">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
|
|
@ -66,18 +56,18 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
1. **Obtain a pairing file**
|
1. **Obtain a pairing file**
|
||||||
- Detailed guide: [Pairing File Instructions](https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord).
|
- Detailed guide: [Pairing File Instructions](https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md) (or ask in Discord).
|
||||||
|
|
||||||
2. **Set up VPN**
|
2. **Set up VPN**
|
||||||
- Launch LocalDevVPN and enable the VPN.
|
- Launch LocalDevVPN and enable the VPN.
|
||||||
|
|
||||||
4. **Enable JIT for an app**
|
4. **Enable JIT for an app**
|
||||||
- Launch StikDebug and tapp the `Enable JIT` button.
|
- Launch StikDebug and tap the `Enable JIT` button.
|
||||||
- Select your sideloaded app from the list in StikDebug.
|
- Select your sideloaded app from the list in StikDebug.
|
||||||
|
|
||||||
**Troubleshooting**
|
**Troubleshooting**
|
||||||
- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings.
|
- "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings.
|
||||||
- Heartbeat errors → Ensure that the VPN is on and that you are connecected to Wi-Fi. It may be a pairing file issue.
|
- Heartbeat errors → Ensure that the VPN is on and that you are connected to Wi-Fi. It may be a pairing file issue.
|
||||||
- Pairing file issues → Replace file with device unlocked & trusted.
|
- Pairing file issues → Replace file with device unlocked & trusted.
|
||||||
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
|
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
|
||||||
|
|
||||||
|
|
@ -96,6 +86,10 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Integrating StikDebug or StikJIT into your own project?**
|
||||||
|
> See the [StikJIT Integration Guide](https://github.com/StikDebug/StikJIT/blob/main/INTEGRATION.md) for integration instructions.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- macOS (latest recommended)
|
- macOS (latest recommended)
|
||||||
- Xcode 16+ (Xcode 26+ preferred for iOS 26+ support)
|
- Xcode 16+ (Xcode 26+ preferred for iOS 26+ support)
|
||||||
|
|
@ -106,7 +100,7 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com
|
||||||
### Steps
|
### Steps
|
||||||
1. **Clone the repo**
|
1. **Clone the repo**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/StephenDev0/StikDebug.git
|
git clone https://github.com/StikDebug/StikDebug.git
|
||||||
cd StikDebug
|
cd StikDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -146,13 +140,11 @@ To propose a new feature, open a feature request issue and provide:
|
||||||
|
|
||||||
### Code Contributions (Best Practices)
|
### Code Contributions (Best Practices)
|
||||||
- Follow normal Swift and SwiftUI style.
|
- Follow normal Swift and SwiftUI style.
|
||||||
- Write clear and easy to understand code.
|
- Write clear and easy-to-understand code.
|
||||||
- Keep your changes consistent with how the project is already set up.
|
- Keep your changes consistent with how the project is already set up.
|
||||||
- Make sure everything builds and works without errors.
|
- Make sure everything builds and works without errors.
|
||||||
|
|
||||||
We appreciate your time and effort in helping improve this project.
|
We appreciate your time and effort in helping improve this project.
|
||||||
|
|
||||||
## Code Help
|
|
||||||
[](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.
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,7 @@
|
||||||
"$(PROJECT_DIR)/StikDebug/idevice",
|
"$(PROJECT_DIR)/StikDebug/idevice",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 3.1.8;
|
MARKETING_VERSION = 3.1.15;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
@ -544,7 +544,7 @@
|
||||||
"$(PROJECT_DIR)/StikDebug/idevice",
|
"$(PROJECT_DIR)/StikDebug/idevice",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||||
MARKETING_VERSION = 3.1.8;
|
MARKETING_VERSION = 3.1.15;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
66
StikDebug/Services/IdeviceLogFileManager.swift
Normal file
66
StikDebug/Services/IdeviceLogFileManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
StikDebug/Services/LocationSimulationSession.swift
Normal file
68
StikDebug/Services/LocationSimulationSession.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,17 @@ extension ScriptStore {
|
||||||
appNames: [
|
appNames: [
|
||||||
"Amethyst",
|
"Amethyst",
|
||||||
"MeloNX",
|
"MeloNX",
|
||||||
"Melo",
|
|
||||||
"XeniOS",
|
"XeniOS",
|
||||||
"MeloCafe",
|
"MeloCafé",
|
||||||
"Manic EMU",
|
"Manic EMU",
|
||||||
"Manic",
|
|
||||||
"DukeX",
|
"DukeX",
|
||||||
"TachyonU",
|
"TachyonU",
|
||||||
|
"touchHLE",
|
||||||
|
"HyperHLE",
|
||||||
|
"Applesauce",
|
||||||
|
"RPCS3",
|
||||||
|
"AetherPS4",
|
||||||
|
"Husk"
|
||||||
],
|
],
|
||||||
resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
|
resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
enum PairingFileStore {
|
enum PairingFileStore {
|
||||||
static let fileName = "rp_pairing_file.plist"
|
static let fileName = "pairingFile.plist"
|
||||||
static let supportedContentTypes: [UTType] = [
|
static let supportedContentTypes: [UTType] = [
|
||||||
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
|
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
|
||||||
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
|
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
|
||||||
.propertyList
|
.propertyList
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let legacyFileName = "pairingFile.plist"
|
private static let legacyFileName = "rp_pairing_file.plist"
|
||||||
|
|
||||||
static var url: URL {
|
static var url: URL {
|
||||||
directoryURL.appendingPathComponent(fileName)
|
directoryURL.appendingPathComponent(fileName)
|
||||||
|
|
@ -25,31 +25,31 @@ enum PairingFileStore {
|
||||||
let destination = url
|
let destination = url
|
||||||
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
guard !fileManager.fileExists(atPath: destination.path) else {
|
if let documentURL = documentSourceURL(fileManager: fileManager) {
|
||||||
removeLegacyCopies(fileManager: fileManager)
|
if !fileManager.fileExists(atPath: destination.path) ||
|
||||||
|
!fileManager.contentsEqual(atPath: documentURL.path, andPath: destination.path) {
|
||||||
|
try? replaceItem(at: destination, with: documentURL, fileManager: fileManager)
|
||||||
|
protectPairingFile(at: destination, fileManager: fileManager)
|
||||||
|
}
|
||||||
return destination
|
return destination
|
||||||
}
|
}
|
||||||
|
|
||||||
migrateLegacyCopy(to: destination, fileManager: fileManager)
|
if !fileManager.fileExists(atPath: destination.path) {
|
||||||
|
migrateLegacyCopy(to: destination, fileManager: fileManager)
|
||||||
|
}
|
||||||
return destination
|
return destination
|
||||||
}
|
}
|
||||||
|
|
||||||
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
|
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||||
let destination = prepareURL(fileManager: fileManager)
|
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let tempURL = directoryURL.appendingPathComponent(UUID().uuidString + ".tmp")
|
let documentsDestination = documentsURL
|
||||||
try? fileManager.removeItem(at: tempURL)
|
if sourceURL.standardizedFileURL != documentsDestination.standardizedFileURL {
|
||||||
try fileManager.copyItem(at: sourceURL, to: tempURL)
|
try replaceItem(at: documentsDestination, with: sourceURL, fileManager: fileManager)
|
||||||
defer { try? fileManager.removeItem(at: tempURL) }
|
|
||||||
|
|
||||||
if fileManager.fileExists(atPath: destination.path) {
|
|
||||||
_ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
|
|
||||||
} else {
|
|
||||||
try fileManager.moveItem(at: tempURL, to: destination)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeLegacyCopies(fileManager: fileManager)
|
try replaceItem(at: url, with: documentsDestination, fileManager: fileManager)
|
||||||
protectPairingFile(at: destination, fileManager: fileManager)
|
protectPairingFile(at: url, fileManager: fileManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
|
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||||
|
|
@ -83,19 +83,33 @@ enum PairingFileStore {
|
||||||
|
|
||||||
private static var legacyURLs: [URL] {
|
private static var legacyURLs: [URL] {
|
||||||
[
|
[
|
||||||
URL.documentsDirectory.appendingPathComponent(fileName),
|
directoryURL.appendingPathComponent(legacyFileName),
|
||||||
|
documentsURL,
|
||||||
URL.documentsDirectory.appendingPathComponent(legacyFileName)
|
URL.documentsDirectory.appendingPathComponent(legacyFileName)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static var documentsURL: URL {
|
||||||
|
URL.documentsDirectory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func documentSourceURL(fileManager: FileManager) -> URL? {
|
||||||
|
[documentsURL, URL.documentsDirectory.appendingPathComponent(legacyFileName)]
|
||||||
|
.filter { fileManager.fileExists(atPath: $0.path) }
|
||||||
|
.max {
|
||||||
|
let firstDate = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
|
||||||
|
let secondDate = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
|
||||||
|
return firstDate < secondDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) {
|
private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) {
|
||||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||||
do {
|
do {
|
||||||
try fileManager.moveItem(at: legacyURL, to: destination)
|
try replaceItem(at: destination, with: legacyURL, fileManager: fileManager)
|
||||||
} catch {
|
} catch {
|
||||||
if let data = try? Data(contentsOf: legacyURL) {
|
if let data = try? Data(contentsOf: legacyURL) {
|
||||||
try? data.write(to: destination, options: .atomic)
|
try? data.write(to: destination, options: .atomic)
|
||||||
try? fileManager.removeItem(at: legacyURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +118,21 @@ enum PairingFileStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func replaceItem(at destination: URL, with source: URL, fileManager: FileManager) throws {
|
||||||
|
let temporary = destination
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent(UUID().uuidString + ".tmp")
|
||||||
|
try? fileManager.removeItem(at: temporary)
|
||||||
|
try fileManager.copyItem(at: source, to: temporary)
|
||||||
|
defer { try? fileManager.removeItem(at: temporary) }
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: destination.path) {
|
||||||
|
_ = try fileManager.replaceItemAt(destination, withItemAt: temporary)
|
||||||
|
} else {
|
||||||
|
try fileManager.moveItem(at: temporary, to: destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func removeLegacyCopies(fileManager: FileManager) {
|
private static func removeLegacyCopies(fileManager: FileManager) {
|
||||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||||
try? fileManager.removeItem(at: legacyURL)
|
try? fileManager.removeItem(at: legacyURL)
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,9 @@ extension UserDefaults {
|
||||||
static let defaultScriptName = "DefaultScriptName"
|
static let defaultScriptName = "DefaultScriptName"
|
||||||
static let defaultScriptNameValue = ""
|
static let defaultScriptNameValue = ""
|
||||||
static let targetDeviceIP = "TunnelDeviceIP"
|
static let targetDeviceIP = "TunnelDeviceIP"
|
||||||
|
/// Attaches processes with MallocGuardEdges and MallocScribble.
|
||||||
|
static let mallocDebug = "enableMallocDebug"
|
||||||
|
static let mallocGuardEdges = "enableMallocGuardEdges"
|
||||||
|
static let mallocScribble = "enableMallocScribble"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum SettingsLinks {
|
private enum SettingsLinks {
|
||||||
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!
|
static let githubStars = URL(string: "https://github.com/StikDebug/StikDebug")!
|
||||||
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!
|
static let pairingFileGuide = URL(string: "https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md")!
|
||||||
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
|
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
|
||||||
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
|
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ struct SettingsView: View {
|
||||||
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
|
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
|
||||||
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
|
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
|
||||||
@AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress
|
@AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress
|
||||||
|
@AppStorage(UserDefaults.Keys.mallocDebug) private var mallocDebug = false
|
||||||
|
|
||||||
@State private var isShowingPairingFilePicker = false
|
@State private var isShowingPairingFilePicker = false
|
||||||
@State private var isImportingFile = false
|
@State private var isImportingFile = false
|
||||||
|
|
@ -94,8 +95,7 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: keepAliveAudio) { _, enabled in
|
.onChange(of: keepAliveAudio) { _, enabled in
|
||||||
if enabled { BackgroundAudioManager.shared.start() }
|
handleAudioKeepAliveChange(enabled)
|
||||||
else { BackgroundAudioManager.shared.stop() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle(isOn: $keepAliveLocation) {
|
Toggle(isOn: $keepAliveLocation) {
|
||||||
|
|
@ -106,7 +106,7 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: keepAliveLocation) { _, enabled in
|
.onChange(of: keepAliveLocation) { _, enabled in
|
||||||
if !enabled { BackgroundLocationManager.shared.stop() }
|
handleLocationKeepAliveChange(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
} header: {
|
} header: {
|
||||||
|
|
@ -131,6 +131,16 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Debugging") {
|
||||||
|
Toggle(isOn: $mallocDebug) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Malloc Debugging")
|
||||||
|
Text("Attaches processes with MallocGuardEdges and MallocScribble to detect memory corruption.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Advanced") {
|
Section("Advanced") {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Target Device IP")
|
Text("Target Device IP")
|
||||||
|
|
@ -231,6 +241,25 @@ struct SettingsView: View {
|
||||||
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
|
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAudioKeepAliveChange(_ enabled: Bool) {
|
||||||
|
if !enabled, !keepAliveLocation {
|
||||||
|
keepAliveLocation = true
|
||||||
|
BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
|
||||||
|
}
|
||||||
|
BackgroundAudioManager.shared.configurationDidChange()
|
||||||
|
BackgroundLocationManager.shared.configurationDidChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLocationKeepAliveChange(_ enabled: Bool) {
|
||||||
|
if enabled {
|
||||||
|
BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
|
||||||
|
} else if !keepAliveAudio {
|
||||||
|
keepAliveAudio = true
|
||||||
|
}
|
||||||
|
BackgroundAudioManager.shared.configurationDidChange()
|
||||||
|
BackgroundLocationManager.shared.configurationDidChange()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Business Logic
|
// MARK: - Business Logic
|
||||||
|
|
||||||
private func openAppFolder() {
|
private func openAppFolder() {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue