Compare commits

..

No commits in common. "main" and "3.1.6" have entirely different histories.
main ... 3.1.6

34 changed files with 519 additions and 4752 deletions

View file

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

View file

@ -3,20 +3,17 @@ name: Build Debug IPA
on:
push:
branches: [ "main" ]
tags: [ "[0-9]*" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: write
contents: read
jobs:
build:
name: Build IPA
runs-on: macos-latest
outputs:
version: ${{ steps.version.outputs.version }}
env:
UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'true' }}
@ -27,7 +24,7 @@ jobs:
- name: 2. Configure Xcode Environment
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.6'
xcode-version: '26.0.1'
- name: 3. Compile Project Archive
run: |
@ -53,70 +50,10 @@ jobs:
zip -r StikDebug.ipa Payload
rm -rf Payload StikDebug.app
- name: 5. Name workflow artifact
id: artifact
- name: 5. Store IPA as Workflow 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'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
path: ${{ steps.artifact.outputs.file_name }}
archive: false
name: StikDebug-Debug.ipa
path: StikDebug.ipa
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 }}'

104
.github/workflows/updatesource.yml vendored Normal file
View file

@ -0,0 +1,104 @@
name: Update StikJIT Source
on:
release:
types: [published]
workflow_dispatch:
# Add permissions block to ensure the workflow can push changes
permissions:
contents: write
jobs:
update-source:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: 0-Blu/StikJIT
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Record job start time
id: job_start_time
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
- name: Debug repository information
run: |
echo "Repository: 0-Blu/StikJIT"
echo "Event name: $GITHUB_EVENT_NAME"
echo "Current directory: $(pwd)"
echo "Repository contents:"
ls -la
- name: Update StikJIT source
id: update_source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure git
git config --global user.name 'GitHub Action'
git config --global user.email 'action@github.com'
# Run the update script
python update_json.py
# Check for changes and commit if needed
git add repo.json
if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes detected in repo.json"
else
echo "Changes detected in repo.json:"
git diff --staged
git commit -m "Updated source with latest StikJIT release"
# Push to main branch of original repository
git push origin main
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Calculate job duration
id: duration
if: always()
run: |
end_time=$(date +%s)
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
- name: Create job summary
run: |
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
echo "## Update StikJIT Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "✅ Changes Detected and Pushed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The repo.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
else
echo "## Update StikJIT Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The repo.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This could be because:" >> $GITHUB_STEP_SUMMARY
echo "- The release version is already in the repo.json file" >> $GITHUB_STEP_SUMMARY
echo "- The release doesn't contain an IPA file" >> $GITHUB_STEP_SUMMARY
echo "- The release tag doesn't match the expected version format" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The next scheduled run will be triggered when a new release is published." >> $GITHUB_STEP_SUMMARY

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,8 +39,7 @@ private enum IdeviceBridge {
}
let code = Int(ffiError.pointee.code)
let detail = string(from: ffiError.pointee.message) ?? fallback
let message = detail == fallback ? fallback : "\(fallback): \(detail)"
let message = string(from: ffiError.pointee.message) ?? fallback
idevice_error_free(ffiError)
return makeError(domain: domain, code: code, message: message)
}
@ -263,69 +262,31 @@ private enum IdeviceBridge {
}
extension JITEnableContext {
func isDeveloperDiskImageMounted() throws -> Bool {
func getMountedDeviceCount() throws -> Int {
try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in
var cryptexQuerySucceeded = false
var cryptexQueryError: NSError?
if DeveloperDiskImageService.usesCryptexDDI {
var installed: UnsafeMutablePointer<InstalledCryptexC>?
defer { cryptexd_free_installed_cryptex(installed) }
if let ffiError = cryptexd_installed_ddi(adapter, handshake, &installed) {
cryptexQueryError = IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to query installed DDI cryptex")
} else {
cryptexQuerySucceeded = true
if installed != nil {
return true
}
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 ffiError = image_mounter_copy_devices(client, &devices, &deviceCount) {
throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to fetch mounted devices")
}
}
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])
}
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
idevice_data_free(
UnsafeMutableRawPointer(devices).assumingMemoryBound(to: UInt8.self),
UInt(deviceCount * MemoryLayout<plist_t?>.stride)
)
}
} 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")
return deviceCount
}
}
}
@ -346,7 +307,13 @@ extension JITEnableContext {
if let ffiError = lockdownd_get_value(lockdownClient, "UniqueChipID", nil, &uniqueChipIDPlist) {
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")
}
@ -356,10 +323,10 @@ extension JITEnableContext {
connect: { image_mounter_connect_rsd(adapter, handshake, $0) },
cleanup: { image_mounter_free($0) }
) { imageMounterClient in
let ffiError = imageData.withUnsafeBytes { imageBuffer in
trustcacheData.withUnsafeBytes { trustcacheBuffer in
manifestData.withUnsafeBytes { manifestBuffer in
image_mounter_mount_personalized_rsd(
let ffiError = imageData.withUnsafeBytes { imageBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
trustcacheData.withUnsafeBytes { trustcacheBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
manifestData.withUnsafeBytes { manifestBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
image_mounter_mount_personalized_with_callback_rsd(
imageMounterClient,
adapter,
handshake,
@ -370,7 +337,9 @@ extension JITEnableContext {
manifestBuffer.bindMemory(to: UInt8.self).baseAddress,
manifestData.count,
nil,
uniqueChipID
uniqueChipID,
progressCallback,
nil
)
}
}

View file

@ -17,37 +17,6 @@ typealias SyslogErrorHandler = (NSError?) -> Void
final class 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 {
var adapter: OpaquePointer?
var handshake: OpaquePointer?
@ -82,7 +51,11 @@ final class JITEnableContext {
var handshakeHandle: OpaquePointer? { handshake }
private init() {
var path = Array(IdeviceLogFileManager.logURL.path.utf8CString)
let logURL = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("idevice_log.txt")
var path = Array(logURL.path.utf8CString)
path.withUnsafeMutableBufferPointer { buffer in
_ = idevice_init_logger(Info, Debug, buffer.baseAddress)
}
@ -117,8 +90,7 @@ final class JITEnableContext {
guard let ffiError else {
return makeError(fallback)
}
let detail = nsString(from: ffiError.pointee.message, fallback: fallback)
let message = detail == fallback ? fallback : "\(fallback): \(detail)"
let message = nsString(from: ffiError.pointee.message, fallback: fallback)
let error = makeError(message, code: Int(ffiError.pointee.code))
idevice_error_free(ffiError)
return error
@ -216,9 +188,7 @@ final class JITEnableContext {
tunnelLock.unlock()
if let waitSemaphore {
guard waitSemaphore.wait(timeout: .now() + .seconds(15)) == .success else {
throw makeError("Timed out waiting for the tunnel connection", code: -19)
}
waitSemaphore.wait()
waitSemaphore.signal()
}
@ -458,19 +428,22 @@ final class JITEnableContext {
private func withConnectedRemoteServer<T>(
_ body: (OpaquePointer) throws -> T
) throws -> T {
try withFreshDebugTunnel(hostname: "StikDebugRemoteServer") { adapter, handshake in
var remoteServer: OpaquePointer?
if let ffiError = remote_server_connect_rsd(adapter, handshake, &remoteServer) {
throw error(from: ffiError, fallback: "Failed to connect remote server")
}
guard let remoteServer else {
throw makeError("Remote server handle was not created")
}
defer { remote_server_free(remoteServer) }
return try body(remoteServer)
try ensureTunnel()
guard let adapter, let handshake else {
throw makeError("Tunnel is not connected")
}
var remoteServer: OpaquePointer?
if let ffiError = remote_server_connect_rsd(adapter, handshake, &remoteServer) {
throw error(from: ffiError, fallback: "Failed to connect remote server")
}
guard let remoteServer else {
throw makeError("Remote server handle was not created")
}
defer { remote_server_free(remoteServer) }
return try body(remoteServer)
}
private func withProcessControl<T>(
@ -628,9 +601,7 @@ final class JITEnableContext {
try withProcessControl(remoteServer: remoteServer) { processControl in
var pid: UInt64 = 0
let ffiError = bundleID.withCString { bundleID in
Self.withCStringArray(Self.mallocDebugEnvVars()) { envPtr, envCount in
process_control_launch_app(processControl, bundleID, envPtr, envCount, nil, 0, true, false, &pid)
}
process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, true, false, &pid)
}
if let ffiError {
@ -671,46 +642,6 @@ final class JITEnableContext {
}
}
func relaunchApp(_ bundleID: String, logger: LogFunc? = nil) -> Bool {
do {
let pid = try withConnectedRemoteServer { remoteServer in
try relaunchApp(bundleID, remoteServer: remoteServer)
}
emitLog("Returned to \(bundleID) (PID \(pid))", logger: logger)
return true
} catch {
emitLog("Failed to return to \(bundleID): \(error.localizedDescription)", logger: logger)
return false
}
}
func relaunchApp(_ bundleID: String, usingRemoteServer remoteServer: OpaquePointer, logger: LogFunc? = nil) -> Bool {
do {
let pid = try relaunchApp(bundleID, remoteServer: remoteServer)
emitLog("Returned to \(bundleID) (PID \(pid))", logger: logger)
return true
} catch {
emitLog("Failed to return to \(bundleID): \(error.localizedDescription)", logger: logger)
return false
}
}
private func relaunchApp(_ bundleID: String, remoteServer: OpaquePointer) throws -> UInt64 {
try withProcessControl(remoteServer: remoteServer) { processControl in
var pid: UInt64 = 0
let ffiError = bundleID.withCString { bundleID in
process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, false, false, &pid)
}
if let ffiError {
throw error(from: ffiError, fallback: "Failed to return to app")
}
return pid
}
}
func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) {
do {
try ensureTunnel()

View file

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

View file

@ -13,6 +13,10 @@ typealias RsdHandshakeHandle = OpaquePointer
typealias ImageMounterHandle = 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 {
case mounted
case notMounted
@ -25,25 +29,18 @@ func isMounted() -> Bool {
func checkMountStatus() -> MountCheckResult {
do {
return try JITEnableContext.shared.isDeveloperDiskImageMounted() ? .mounted : .notMounted
let result = try JITEnableContext.shared.getMountedDeviceCount()
return result > 0 ? .mounted : .notMounted
} catch {
LogManager.shared.addErrorLog("Failed to check DDI mount status: \(error.localizedDescription)")
return .unreachable
}
}
func mountDeveloperDiskImage(from directoryPath: String) -> String? {
func mountPersonalDDI(imagePath: String, trustcachePath: String, manifestPath: String) -> String? {
do {
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
)
}
try JITEnableContext.shared.mountPersonalDDI(withImagePath: imagePath, trustcachePath: trustcachePath, manifestPath: manifestPath)
} catch {
LogManager.shared.addErrorLog("Failed to mount DDI: \(error.localizedDescription)")
return error.localizedDescription
}
return nil

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,6 @@
//
import CoreLocation
import UIKit
final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
static let shared = BackgroundLocationManager()
@ -12,7 +11,6 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private var isRunning = false
private var activityCount = 0
private var isRequestingAuthorization = false
private override init() {
super.init()
@ -23,44 +21,8 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
locationManager.pausesLocationUpdatesAutomatically = false
}
func configurationDidChange() {
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
}
func start() {
isRunning = true
switch locationManager.authorizationStatus {
case .authorizedAlways:
locationManager.startUpdatingLocation()
@ -68,54 +30,37 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
locationManager.requestAlwaysAuthorization()
case .notDetermined:
locationManager.requestAlwaysAuthorization()
case .denied, .restricted:
restoreAudioFallback()
@unknown default:
restoreAudioFallback()
default:
break
}
}
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) {
guard isRunning else { return }
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
isRequestingAuthorization = false
if isRunning {
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)
manager.startUpdatingLocation()
default:
break
}
}

View file

@ -7,17 +7,6 @@ import Foundation
final class 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 session: URLSession
@ -28,21 +17,13 @@ final class DeveloperDiskImageService {
}
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 {
let destinationURL = Self.directoryURL.appendingPathComponent(item.fileName)
guard replaceFiles || !fileManager.fileExists(atPath: destinationURL.path) else {
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
guard !fileManager.fileExists(atPath: destinationURL.path) else {
continue
}
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 {
@ -75,18 +56,8 @@ final class DeveloperDiskImageService {
var completedStages = 0.0
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 {
let fileURL = Self.directoryURL.appendingPathComponent(item.fileName)
let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
if fileManager.fileExists(atPath: fileURL.path) {
try fileManager.removeItem(at: fileURL)
}
@ -97,100 +68,37 @@ final class DeveloperDiskImageService {
for item in Self.downloadItems {
progressHandler?(completedStages / totalStages, "Downloading \(item.name)...")
let destinationURL = Self.directoryURL.appendingPathComponent(item.fileName)
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
try await downloadFile(from: item.urlString, to: destinationURL)
completedStages += 1.0
progressHandler?(completedStages / totalStages, "\(item.name) ready")
}
try Self.mountMethod.rawValue.write(to: Self.mountMethodURL, atomically: true, encoding: .utf8)
progressHandler?(1.0, "DDI download complete.")
}
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] = [
private static let downloadItems: [DDIDownloadItem] = [
.init(
name: "Build Manifest",
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",
relativePath: "DDI/BuildManifest.plist",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist"
),
.init(
name: "Image",
fileName: "Image.dmg",
relativePath: "DDI/Image.dmg",
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg"
),
.init(
name: "TrustCache",
fileName: "Image.dmg.trustcache",
relativePath: "DDI/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 {
let name: String
let fileName: String
let relativePath: String
let urlString: String
}

View file

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

View file

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

View file

@ -70,7 +70,12 @@ final class TunnelManager: ObservableObject {
}
private func mountDeveloperDiskImageIfNeeded() {
guard DeveloperDiskImageService.filesAreReady else { return }
let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path
guard FileManager.default.fileExists(atPath: trustcachePath),
!MountingProgress.shared.coolisMounted,
MountingProgress.shared.mountingThread == nil else {
return
}
MountingProgress.shared.pubMount()
}
@ -98,11 +103,16 @@ final class TunnelManager: ObservableObject {
}
private func handleInvalidPairingFile() {
LogManager.shared.addInfoLog("Pairing file reported invalid; keeping existing file")
do {
try PairingFileStore.remove()
LogManager.shared.addInfoLog("Removed invalid pairing file")
} catch {
LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)")
}
showAlert(
title: "Invalid Pairing File",
message: "The pairing file may be invalid or expired. You can import a new pairing file to replace it.",
message: "The pairing file is invalid or expired. Please select a new pairing file.",
showOk: true,
showTryAgain: false,
primaryButtonText: "Select New File"

View file

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

View file

@ -7,14 +7,14 @@ import Foundation
import UniformTypeIdentifiers
enum PairingFileStore {
static let fileName = "pairingFile.plist"
static let fileName = "rp_pairing_file.plist"
static let supportedContentTypes: [UTType] = [
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
.propertyList
]
private static let legacyFileName = "rp_pairing_file.plist"
private static let legacyFileName = "pairingFile.plist"
static var url: URL {
directoryURL.appendingPathComponent(fileName)
@ -25,31 +25,24 @@ enum PairingFileStore {
let destination = url
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
if let documentURL = documentSourceURL(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)
}
guard !fileManager.fileExists(atPath: destination.path) else {
removeLegacyCopies(fileManager: fileManager)
return destination
}
if !fileManager.fileExists(atPath: destination.path) {
migrateLegacyCopy(to: destination, fileManager: fileManager)
}
migrateLegacyCopy(to: destination, fileManager: fileManager)
return destination
}
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let documentsDestination = documentsURL
if sourceURL.standardizedFileURL != documentsDestination.standardizedFileURL {
try replaceItem(at: documentsDestination, with: sourceURL, fileManager: fileManager)
let destination = prepareURL(fileManager: fileManager)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
try replaceItem(at: url, with: documentsDestination, fileManager: fileManager)
protectPairingFile(at: url, fileManager: fileManager)
removeLegacyCopies(fileManager: fileManager)
try fileManager.copyItem(at: sourceURL, to: destination)
protectPairingFile(at: destination, fileManager: fileManager)
}
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
@ -83,33 +76,19 @@ enum PairingFileStore {
private static var legacyURLs: [URL] {
[
directoryURL.appendingPathComponent(legacyFileName),
documentsURL,
URL.documentsDirectory.appendingPathComponent(fileName),
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) {
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
do {
try replaceItem(at: destination, with: legacyURL, fileManager: fileManager)
try fileManager.moveItem(at: legacyURL, to: destination)
} catch {
if let data = try? Data(contentsOf: legacyURL) {
try? data.write(to: destination, options: .atomic)
try? fileManager.removeItem(at: legacyURL)
}
}
@ -118,21 +97,6 @@ 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) {
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
try? fileManager.removeItem(at: legacyURL)

View file

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

View file

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

View file

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

View file

@ -12,14 +12,17 @@ struct HomeView: View {
@AppStorage("bundleID") private var bundleID: String = ""
@AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true
@ObservedObject private var mounting = MountingProgress.shared
@State private var hasAppeared = false
@State private var pendingJITEnableConfiguration: JITEnableConfiguration?
@State private var isShowingPairingFilePicker = false
@State private var debugFeedback: DebugFeedback?
@State private var pendingExternalURLAction: HomeExternalAction?
@State private var pendingLiveContainerApp: LiveContainerApp?
@State private var scriptRunModel: RunJSViewModel?
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private struct DebugFeedback: Identifiable {
let id = UUID()
let message: String
@ -27,18 +30,11 @@ struct HomeView: View {
let isWorking: Bool
}
private struct LiveContainerApp {
let bundleID: String
let name: String
}
var body: some View {
InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in
if selectedName.localizedCaseInsensitiveContains("LiveContainer") {
pendingLiveContainerApp = LiveContainerApp(bundleID: selectedBundle, name: selectedName)
} else {
enableJITForSelectedApp(bundleID: selectedBundle, name: selectedName)
}
bundleID = selectedBundle
Haptics.medium()
startJITInBackground(bundleID: selectedBundle, displayName: selectedName)
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
.overlay(alignment: .bottom) {
if let debugFeedback {
@ -49,6 +45,9 @@ struct HomeView: View {
}
.onAppear(perform: handleAppear)
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification)
.onReceive(timer) { _ in
refreshMountStatusIfNeeded()
}
.onOpenURL { url in
handleExternalURL(url)
}
@ -75,28 +74,6 @@ struct HomeView: View {
} message: { action in
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(
isPresented: $isShowingPairingFilePicker,
allowedContentTypes: PairingFileStore.supportedContentTypes,
@ -115,14 +92,9 @@ struct HomeView: View {
}
}
private func enableJITForSelectedApp(bundleID selectedBundle: String, name: String) {
bundleID = selectedBundle
Haptics.medium()
startJITInBackground(bundleID: selectedBundle, displayName: name)
}
private func handleAppear() {
startTunnelInBackground()
MountingProgress.shared.checkforMounted()
hasAppeared = true
if let config = pendingJITEnableConfiguration {
@ -145,6 +117,13 @@ struct HomeView: View {
scriptRunModel = model
}
private func refreshMountStatusIfNeeded() {
guard mounting.mountingThread == nil, !mounting.coolisMounted else {
return
}
MountingProgress.shared.checkforMounted()
}
private func importPairingFile(_ result: Result<URL, Error>) {
switch result {
case .success(let url):
@ -196,14 +175,6 @@ struct HomeView: View {
}
if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) {
config.scriptName = scriptName
if config.scriptData == nil {
if let namedScript = ScriptStore.script(named: scriptName) {
config.scriptData = namedScript.data
config.scriptName = namedScript.name
} else {
LogManager.shared.addWarningLog("Script \(scriptName) was not found in the scripts folder")
}
}
}
if config.scriptData == nil, let bundleID = config.bundleID,
let scriptInfo = ScriptStore.preferredScript(for: bundleID) {
@ -288,13 +259,12 @@ struct HomeView: View {
.accessibilityLabel(feedback.message)
}
private func getJsCallback(_ script: Data, name: String? = nil, resumeBundleID: String? = nil) -> DebugAppCallback {
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
let model = RunJSViewModel(pid: Int(pid),
debugProxy: debugProxyHandle,
remoteServer: remoteServerHandle,
semaphore: semaphore,
resumeBundleID: resumeBundleID)
semaphore: semaphore)
DispatchQueue.main.async {
scriptRunModel = model
@ -329,18 +299,8 @@ struct HomeView: View {
let keepAliveLease = DebugKeepAliveLease()
defer { keepAliveLease.invalidate() }
if triggeredByURLScheme, !waitForJITPrerequisites() {
DispatchQueue.main.async {
withAnimation {
debugFeedback = nil
}
showAlert(
title: "Failed to Enable JIT".localized,
message: "The device connection or Developer Disk Image wasn't ready in time. Open StikDebug directly, wait for it to finish connecting, then try again.".localized,
showOk: true
)
}
return
if triggeredByURLScheme {
sleep(1)
}
let finishProcessing: (Bool, String?) -> Void = { success, detail in
@ -378,11 +338,9 @@ struct HomeView: View {
scriptData = preferred.data
}
let resumeBundleID = pid == nil ? nil : bundleID
var callback: DebugAppCallback? = nil
if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script", resumeBundleID: resumeBundleID)
callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script")
}
var lastDebugMessage: String?
@ -415,17 +373,6 @@ struct HomeView: View {
}
}
private func waitForJITPrerequisites(timeout: TimeInterval = 20) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted {
return true
}
usleep(250_000)
}
return TunnelManager.shared.isConnected && MountingProgress.shared.coolisMounted
}
private func base64URLToBase64(_ base64url: String) -> String {
var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let pad = 4 - (base64.count % 4)

View file

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

View file

@ -7,7 +7,6 @@
import SwiftUI
import Foundation
import CoreLocation
private enum ExternalLocationAction: Identifiable {
case simulate(URL, Double, Double)
@ -224,22 +223,7 @@ struct MainTabView: View {
DispatchQueue.main.async {
if code == 0 {
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
)
}
}
BackgroundLocationManager.shared.requestStart()
LogManager.shared.addInfoLog(
String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude)
)
@ -259,7 +243,7 @@ struct MainTabView: View {
let code = clear_simulated_location()
DispatchQueue.main.async {
if code == 0 {
LocationSimulationSession.shared.stop()
BackgroundLocationManager.shared.requestStop()
LogManager.shared.addInfoLog("Cleared simulated location from URL")
} else {
showAlert(

View file

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

View file

@ -7,8 +7,8 @@ import SwiftUI
import UIKit
private enum SettingsLinks {
static let githubStars = URL(string: "https://github.com/StikDebug/StikDebug")!
static let pairingFileGuide = URL(string: "https://github.com/StikDebug/StikDebug-Guide/blob/main/pairing_file.md")!
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
}
@ -18,8 +18,6 @@ struct SettingsView: View {
@AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress
@AppStorage(UserDefaults.Keys.mallocDebug) private var mallocDebug = false
@State private var isShowingPairingFilePicker = false
@State private var isImportingFile = false
@ -95,7 +93,8 @@ struct SettingsView: View {
}
}
.onChange(of: keepAliveAudio) { _, enabled in
handleAudioKeepAliveChange(enabled)
if enabled { BackgroundAudioManager.shared.start() }
else { BackgroundAudioManager.shared.stop() }
}
Toggle(isOn: $keepAliveLocation) {
@ -106,7 +105,7 @@ struct SettingsView: View {
}
}
.onChange(of: keepAliveLocation) { _, enabled in
handleLocationKeepAliveChange(enabled)
if !enabled { BackgroundLocationManager.shared.stop() }
}
} header: {
@ -131,27 +130,12 @@ 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") {
HStack {
Text("Target Device IP")
Spacer()
TextField(DeviceConnectionContext.defaultTargetIPAddress, text: $targetDeviceIP)
.multilineTextAlignment(.trailing)
Text(DeviceConnectionContext.defaultTargetIPAddress)
.foregroundStyle(.secondary)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.numbersAndPunctuation)
.frame(maxWidth: 160)
}
Button { openAppFolder() } label: {
Label("App Folder", systemImage: "folder")
@ -241,25 +225,6 @@ struct SettingsView: View {
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
private func openAppFolder() {

File diff suppressed because it is too large Load diff

Binary file not shown.