Semi-Rewrite – Major Refactor & Codebase Cleanup (#328)

* Change default UPLOAD_IPA value to 'false'

* Part 1 of the great debloat

* Remove Mini Tools, adopt native alerts in StikJITApp, and update HomeView UI

* Refactor views to use native SwiftUI alerts instead of CustomErrorView

* Update ProfileView to use minimal List UI style with native alerts

* Fix up homeview

* Squash: Redesign HomeView, remove Feature Flags, enable all tabs, refactor alerts, and updates

- Redesign HomeView to a simpler style with 'Enable JIT' action.
- Remove 'Help & Info' and 'Quick Launch' from Home.
- Fix DDI false positive by starting heartbeat on HomeView appear.
- Restore robust DDI checks and update app name to 'StikDebug'.
- Remove FeatureFlags and Beta tags; enable 'profiles', 'processes', 'location' tabs by default.
- Remove 'Devices' tab; add 'Target Device IP' field in Settings.
- Hardcode 'Settings' tab to 4th position.
- Refactor 'SettingsView' tab customization to be reorder-only.
- Remove 'Appearance' theme section from Settings.
- Refactor custom error cards to use native SwiftUI/UIKit alerts across the app (HomeView, ConsoleLogsView, ProfileView, etc).
- Restore JS callback support and script execution logic in HomeView.
- Add necessary Color extensions.
- Update project version to 3.0.0.

* Clean up: Remove In-App Purchases, update checker, and unused IPA installer code

* delete more bloat

* fix build issue

* Remove widget to save on App IDs

* Attempt heartbeat restart when Enable JIT button is pressed

* Get rid of PIP

* Update Tab Naming

* remove more bloat

* final changes for today

* Remove unused packages

* UX Improvements

* revert bundle id

* Update README.md

* remove old idevice

* add updated idevice

* Fix ddi false positive errors and add templates for GitHub stuff

* Remove more unused stuff.

* Fix links and update README content

* Update compatibility table in README.md

* Improve performance of console logs

* Remove old code

* Change default UPLOAD_IPA value to 'true'

* remove the welcome sheet

* import support for .mobiledevicepair

* Add back LC JIT stuff

* Fix heartbeat issues with LC JIT

* update the build yml for nightly stuff

* Update README.md

* Update README.md

* Update GitHub Actions workflow for IPA build

* Update GitHub Actions workflow for nightly builds

* Add URL scheme to kill tasks stikjit://kill-process?pid=1234

* fix build issues

* Added App Intents

* Unify the error handling style across the app
This commit is contained in:
Stephen 2026-02-25 13:44:00 -05:00 • committed by GitHub
parent 7b103175b2
commit f3d2dec2e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2923 additions and 10252 deletions

66
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill out the information below to help us fix it.
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the bug.
placeholder: Describe what happened...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Go to ...
2. Tap on ...
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: input
id: ios-version
attributes:
label: iOS Version
placeholder: e.g. 18.3.1
validations:
required: true
- type: input
id: device
attributes:
label: Device
placeholder: e.g. iPhone 15 Pro
validations:
required: true
- type: input
id: app-version
attributes:
label: StikDebug Version
placeholder: e.g. 1.0.0
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: Any relevant logs from the app's log viewer.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain the issue.

View file

@ -0,0 +1,27 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have an idea for StikDebug? Let us know!
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the feature you'd like.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use Case
description: Why would this be useful? What problem does it solve?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?

21
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,21 @@
## Summary
<!-- Brief description of what this PR does -->
## Changes
-
## Related Issues
<!-- Link any related issues: Fixes #123, Closes #456 -->
## Testing
- [ ] Tested on device
- [ ] Tested with fresh pairing file
- [ ] No regressions in existing functionality
## Screenshots
<!-- If applicable, add screenshots showing the changes -->

View file

@ -2,9 +2,9 @@ name: Build Debug IPA
on:
push:
branches: [ "main" ]
branches: [ "main", "semi-rewrite" ]
pull_request:
branches: [ "main" ]
branches: [ "main", "semi-rewrite" ]
workflow_dispatch:
permissions:
@ -71,7 +71,7 @@ jobs:
tag_name: Nightly
name: "Nightly Build - v${{ steps.version.outputs.app_version }}"
prerelease: true
generate_release_notes: false
generate_release_notes: true
target_commitish: ${{ github.sha }}
files: |
StikDebug.ipa

View file

@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,35 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,11 +0,0 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,270 +0,0 @@
//
// DebugWidget.swift
// DebugWidget
//
// Created by Stephen on 5/30/25.
//
import WidgetKit
import SwiftUI
import UIKit
// MARK: - Favorites Widget ----------------------------------------------------
struct FavoriteSnapshot: Identifiable {
let bundleID: String
let displayName: String
var id: String { bundleID }
}
struct FavoritesEntry: TimelineEntry {
let date: Date
let items: [FavoriteSnapshot]
}
struct FavoritesProvider: TimelineProvider {
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")
func placeholder(in context: Context) -> FavoritesEntry {
FavoritesEntry(date: .now, items: [])
}
func getSnapshot(in context: Context, completion: @escaping (FavoritesEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FavoritesEntry>) -> Void) {
let entry = makeEntry()
completion(Timeline(entries: [entry], policy: .never))
}
private func makeEntry() -> FavoritesEntry {
let favorites = sharedDefaults?.stringArray(forKey: "favoriteApps") ?? []
let names = sharedDefaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] ?? [:]
let items = favorites.prefix(4).map { bundleID -> FavoriteSnapshot in
let display = names[bundleID] ?? friendlyNameFromBundleID(bundleID)
return FavoriteSnapshot(bundleID: bundleID, displayName: display)
}
return FavoritesEntry(date: .now, items: items)
}
}
struct FavoritesWidgetEntryView: View {
let entry: FavoritesEntry
var body: some View {
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { idx in
if idx < entry.items.count {
labeledIconCell(item: entry.items[idx])
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
}
.padding(8)
.containerBackground(Color(UIColor.systemBackground), for: .widget)
}
@ViewBuilder
private func labeledIconCell(item: FavoriteSnapshot) -> some View {
if let img = loadIcon(for: item.bundleID) {
Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(item.bundleID)")!) {
VStack(spacing: 6) {
Image(uiImage: img)
.resizable()
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
Text(item.displayName)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.65)
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
}
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private func placeholderCell() -> some View {
VStack(spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.systemGray5))
Image(systemName: "plus")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.gray)
}
.aspectRatio(1, contentMode: .fit)
Text(" ")
.font(.caption2)
.opacity(0) // keep height consistent
}
}
}
struct FavoritesWidget: Widget {
let kind: String = "FavoritesWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FavoritesProvider()) { entry in
FavoritesWidgetEntryView(entry: entry)
}
.configurationDisplayName("StikDebug Favorites")
.description("Quick-launch your top 4 favorite debug targets.")
.supportedFamilies([.systemMedium])
}
}
// MARK: - Launch Shortcuts Widget (formerly System Apps) ---------------------
struct SystemAppSnapshot: Identifiable {
let bundleID: String
let displayName: String
var id: String { bundleID }
}
struct SystemAppsEntry: TimelineEntry {
let date: Date
let items: [SystemAppSnapshot]
}
struct SystemAppsProvider: TimelineProvider {
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")
func placeholder(in context: Context) -> SystemAppsEntry {
SystemAppsEntry(date: .now, items: [])
}
func getSnapshot(in context: Context, completion: @escaping (SystemAppsEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SystemAppsEntry>) -> Void) {
let entry = makeEntry()
completion(Timeline(entries: [entry], policy: .never))
}
private func makeEntry() -> SystemAppsEntry {
// This list now represents "pinned launch apps" (system or other)
let pinned = sharedDefaults?.stringArray(forKey: "pinnedSystemApps") ?? []
let names = sharedDefaults?.dictionary(forKey: "pinnedSystemAppNames") as? [String: String] ?? [:]
let snapshots = pinned.prefix(4).map { bundleID -> SystemAppSnapshot in
let displayName = names[bundleID] ?? friendlyName(bundleID: bundleID)
return SystemAppSnapshot(bundleID: bundleID, displayName: displayName)
}
return SystemAppsEntry(date: .now, items: snapshots)
}
private func friendlyName(bundleID: String) -> String {
let components = bundleID.split(separator: ".")
if let last = components.last {
let cleaned = last.replacingOccurrences(of: "_", with: " ")
let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed.capitalized }
}
return bundleID
}
}
struct SystemAppsWidgetEntryView: View {
let entry: SystemAppsEntry
var body: some View {
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { idx in
if idx < entry.items.count {
labeledIconCell(item: entry.items[idx])
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
}
.padding(8)
.containerBackground(Color(UIColor.systemBackground), for: .widget)
}
@ViewBuilder
private func labeledIconCell(item: SystemAppSnapshot) -> some View {
if let img = loadIcon(for: item.bundleID) {
// Use launch-app to mirror non-debug launch behavior
Link(destination: URL(string: "stikjit://launch-app?bundle-id=\(item.bundleID)")!) {
VStack(spacing: 6) {
Image(uiImage: img)
.resizable()
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
Text(item.displayName)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.65)
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
}
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private func placeholderCell() -> some View {
VStack(spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.systemGray5))
Image(systemName: "plus")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.gray)
}
.aspectRatio(1, contentMode: .fit)
Text(" ")
.font(.caption2)
.opacity(0) // keep height consistent
}
}
}
struct SystemAppsWidget: Widget {
let kind: String = "SystemAppsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SystemAppsProvider()) { entry in
SystemAppsWidgetEntryView(entry: entry)
}
.configurationDisplayName("Launch Shortcuts")
.description("Pin any app to launch directly from the widget.")
.supportedFamilies([.systemMedium])
}
}
// MARK: - Shared Helpers -----------------------------------------------------
private func loadIcon(for bundleID: String) -> UIImage? {
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
else { return nil }
let url = container
.appendingPathComponent("icons", isDirectory: true)
.appendingPathComponent("\(bundleID).png")
return UIImage(contentsOfFile: url.path)
}
/// Fallback readable name from a bundle identifier
private func friendlyNameFromBundleID(_ bundleID: String) -> String {
let components = bundleID.split(separator: ".")
if let last = components.last {
let cleaned = last.replacingOccurrences(of: "_", with: " ")
let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed.capitalized }
}
return bundleID
}

View file

@ -1,19 +0,0 @@
//
// DebugWidgetBundle.swift
// DebugWidget
//
// Created by Stephen on 5/30/25.
//
import WidgetKit
import SwiftUI
@main
struct StikDebugWidgetBundle: WidgetBundle {
var body: some Widget {
// Both widgets enabled: Favorites uses enable-jit URL scheme (with PiP/script handled in-app),
// System Apps uses launch-app URL scheme for non-debug launch behavior.
FavoritesWidget()
SystemAppsWidget()
}
}

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.stik.stikdebug</string>
</array>
</dict>
</plist>

131
README.md
View file

@ -7,7 +7,7 @@
<div align="center">
<h1><b>StikDebug</b></h1>
<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/jkcoxsin/idevice">idevice</a>.</i></p>
</div>
<h6 align="center">
@ -15,16 +15,28 @@
<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/0-Blu/StikJIT?label=License&color=5865F2&style=for-the-badge&labelColor=23272A" />
<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/0-Blu/StikJIT?label=Stars&color=FEE75C&style=for-the-badge&labelColor=23272A" />
<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>
# Download
## Features
- **JIT:** Enable Just In Time coplation in 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).
- **Profiles:** Install/remove profile and see when apps will expire.
- **Device Info:** View detailed device metadata.
- **Processes:** Inspect running apps/processes and terminate them.
- **Location Simulator:** Simulate the GPS location of your device.
## Download
> [!WARNING]
> **Notice:** StikDebug is no longer available on the App Store. Please use the official download methods below.
@ -37,12 +49,117 @@
</a>
</div>
## Project Status
## Compatibility
| iOS Version | Status | Notes |
|--------------------------|----------------------|-----------------------------------------------------------------------|
| 1.0 – 17.3.X | Not supported | Uses Different Connection Protocols |
| 17.4 – 18.x | Fully supported | Stable |
| 26.0 – 26.3 | Supported | Limited App Availability (Developers need to update their apps to work.) |
| 26.4 Developer Beta 1 | Critical issues | lockdownd drops connections → JIT broken; do not update |
| iOS 26.4+ betas / future | Untested / partial | Check [Discord](https://discord.gg/ZnNcrRT3M8), [releases](https://github.com/StephenDev0/StikDebug/releases), or [@StephenDev0 on X](https://x.com/stephendev0) for updates |
## How to Enable JIT
StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a computer after the initial pairing setup.
### Requirements
- StikDebug installed (via AltSource, direct .ipa, or self-built)
- A valid **pairing file** (.plist / .mobiledevicepairing) for your device
- SideStore / AltStore / similar sideload tool (for app refreshing)
- A loopback vpn such as [LocalDevVPN](https://apps.apple.com/us/app/localdevvpn/id6755608044)
### Steps
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).
2. **Set up VPN**
- Launch LocalDevVPN and enable the vpn.
4. **Enable JIT for an app**
- Launch StikDebug and press 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 erros → Ensure that the vpn is on and that you are connecected to Wi-Fi.
- Pairing file issues → Regenerate file with device unlocked & trusted.
- Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots.
<!--
## Screenshots
<div align="center">
<img src="screenshots/pairing-import.png" width="320" alt="Pairing file import screen">
<img src="screenshots/app-list.png" width="320" alt="Sideloaded apps list">
<img src="screenshots/jit-enabled.png" width="320" alt="JIT successfully enabled">
<img src="screenshots/processes.png" width="320" alt="Process management tab">
</div>
(Add images to a /screenshots/ folder in the repo and uncomment when ready.)
-->
## Building from Source
> [!NOTE]
> A major code cleanup and rewrite is currently underway on the [`semi-rewrite`](https://github.com/StephenDev0/StikDebug/tree/semi-rewrite) branch to improve stability and maintainability.
> StikDebug is an open-source Xcode project written mostly in Swift. Building yourself lets you modify code, debug, or create custom versions. Requires a Mac with Xcode.
### Requirements
- macOS (latest recommended)
- Xcode 16+ (Xcode 26+ preferred for iOS 26+ support)
- iOS device on iOS 17.4+ (for testing)
- Git
- Basic Xcode/Swift knowledge
### Steps
1. **Clone the repo**
```bash
git clone https://github.com/StephenDev0/StikDebug.git
cd StikDebug
```
2. **Open in Xcode**
- Launch Xcode
- Open `StikDebug.xcodeproj`
3. **Configure signing**
- Select the **StikDebug** target
- Go to **Signing & Capabilities**
- Sign in with your Apple ID (free or paid developer account)
- Set a unique **Bundle Identifier** (e.g., `com.yourname.StikDebug`)
4. **Build & install**
- Select your connected device
- Press **Cmd + R** (or Product → Run)
- Trust the certificate on device: Settings → General → VPN & Device Management
After install, follow the JIT setup steps above (pairing import, etc.).
## Contributing
Thank you for your interest in contributing to this project. Contributions of all kinds are welcome.
### Reporting Bugs
If you discover a bug, please open an issue and include:
- A clear and descriptive title
- Steps to reproduce the issue
- Expected behavior vs. actual behavior
- Relevant logs, screenshots, or environment details (iOS version, device model, etc.)
### Suggesting Features
To propose a new feature, open a feature request issue and provide:
- A clear description of the feature
- The problem it solves or the use case it addresses
- Any relevant examples or implementation ideas
### Code Contributions (Best Practices)
- Follow normal Swift and SwiftUI style.
- 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

@ -7,24 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; };
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; };
68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 68E714E52E6AA2B00025610F /* ZIPFoundation */; };
DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6D2DE97EA400F63846 /* WidgetKit.framework */; };
DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6F2DE97EA400F63846 /* SwiftUI.framework */; };
DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */ = {isa = PBXBuildFile; productRef = DCBA85852E3897BD00E88C06 /* StikImporter */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */;
proxyType = 1;
remoteGlobalIDString = DC139F6B2DE97EA400F63846;
remoteInfo = DebugWidgetExtension;
};
DC6F1D492D94EADF0071B2B6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */;
@ -48,7 +35,6 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@ -56,10 +42,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DebugWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DC139F6D2DE97EA400F63846 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
DC139F6F2DE97EA400F63846 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugWidgetExtension.entitlements; sourceTree = "<group>"; };
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; };
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@ -73,24 +57,9 @@
);
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
};
DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
DC139F712DE97EA400F63846 /* DebugWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */,
);
path = DebugWidget;
sourceTree = "<group>";
};
DC6F1D392D94EADD0071B2B6 /* StikJIT */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@ -112,24 +81,12 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
DC139F692DE97EA400F63846 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */,
DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
DC6F1D342D94EADD0071B2B6 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
17C744F02E20BED000834F17 /* Pipify in Frameworks */,
DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */,
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */,
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */,
68E714E62E6AA2B00025610F /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -153,11 +110,9 @@
DC6F1D2E2D94EADD0071B2B6 = {
isa = PBXGroup;
children = (
DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */,
DC6F1D392D94EADD0071B2B6 /* StikJIT */,
DC6F1D4B2D94EADF0071B2B6 /* StikJITTests */,
DC6F1D552D94EADF0071B2B6 /* StikJITUITests */,
DC139F712DE97EA400F63846 /* DebugWidget */,
DC6F1D752D94EB620071B2B6 /* Frameworks */,
DC6F1D382D94EADD0071B2B6 /* Products */,
);
@ -169,7 +124,6 @@
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */,
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */,
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */,
DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -186,28 +140,6 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */;
buildPhases = (
DC139F682DE97EA400F63846 /* Sources */,
DC139F692DE97EA400F63846 /* Frameworks */,
DC139F6A2DE97EA400F63846 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
DC139F712DE97EA400F63846 /* DebugWidget */,
);
name = DebugWidgetExtension;
packageProductDependencies = (
);
productName = DebugWidgetExtension;
productReference = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
DC6F1D362D94EADD0071B2B6 /* StikDebug */ = {
isa = PBXNativeTarget;
buildConfigurationList = DC6F1D5C2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebug" */;
@ -220,7 +152,6 @@
buildRules = (
);
dependencies = (
DC139F802DE97EA600F63846 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
DC6F1D392D94EADD0071B2B6 /* StikJIT */,
@ -229,9 +160,6 @@
packageProductDependencies = (
68D569BD2E1B415700A5BA36 /* CodeEditorView */,
68D569BF2E1B415700A5BA36 /* LanguageSupport */,
17C744EF2E20BED000834F17 /* Pipify */,
DCBA85852E3897BD00E88C06 /* StikImporter */,
68E714E52E6AA2B00025610F /* ZIPFoundation */,
);
productName = StikJIT;
productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */;
@ -293,9 +221,6 @@
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
DC139F6B2DE97EA400F63846 = {
CreatedOnToolsVersion = 16.2;
};
DC6F1D362D94EADD0071B2B6 = {
CreatedOnToolsVersion = 16.2;
LastSwiftMigration = 1620;
@ -323,9 +248,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */,
17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */,
DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */,
68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = DC6F1D382D94EADD0071B2B6 /* Products */;
@ -335,19 +257,11 @@
DC6F1D362D94EADD0071B2B6 /* StikDebug */,
DC6F1D472D94EADF0071B2B6 /* StikDebugTests */,
DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */,
DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
DC139F6A2DE97EA400F63846 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DC6F1D352D94EADD0071B2B6 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -372,13 +286,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DC139F682DE97EA400F63846 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
DC6F1D332D94EADD0071B2B6 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -403,11 +310,6 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
DC139F802DE97EA600F63846 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */;
targetProxy = DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */;
};
DC6F1D4A2D94EADF0071B2B6 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
@ -421,67 +323,6 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
DC139F822DE97EA600F63846 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2743MYJ5UC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DebugWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = DebugWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.DebugWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
DC139F832DE97EA600F63846 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2743MYJ5UC;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DebugWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = DebugWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.DebugWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
DC6F1D5A2D94EADF0071B2B6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -604,10 +445,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"StikJIT/Preview Content\"";
DEVELOPMENT_TEAM = 2743MYJ5UC;
DEVELOPMENT_TEAM = XXK735HX49;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
@ -619,6 +460,8 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library.";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -638,9 +481,10 @@
"$(PROJECT_DIR)/StikJIT/idevice",
);
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 2.3.7;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@ -662,10 +506,10 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = StikJIT/StikJIT.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"StikJIT/Preview Content\"";
DEVELOPMENT_TEAM = 2743MYJ5UC;
DEVELOPMENT_TEAM = XXK735HX49;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
@ -677,6 +521,8 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library.";
INFOPLIST_KEY_NSLocationAlwaysAndWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "StikDebug uses location to stay running in the background and maintain your JIT connection.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -696,9 +542,10 @@
"$(PROJECT_DIR)/StikJIT/idevice",
);
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 2.3.7;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@ -804,15 +651,6 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DC139F822DE97EA600F63846 /* Debug */,
DC139F832DE97EA600F63846 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@ -852,14 +690,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/hugeBlack/swiftui-pipify";
requirement = {
branch = main;
kind = branch;
};
};
68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mchakravarty/CodeEditorView";
@ -868,30 +698,9 @@
minimumVersion = 0.15.4;
};
};
68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/weichsel/ZIPFoundation";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.9.19;
};
};
DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/StephenDev0/StikImporter";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.2;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
17C744EF2E20BED000834F17 /* Pipify */ = {
isa = XCSwiftPackageProductDependency;
package = 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */;
productName = Pipify;
};
68D569BD2E1B415700A5BA36 /* CodeEditorView */ = {
isa = XCSwiftPackageProductDependency;
package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */;
@ -902,16 +711,6 @@
package = 68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */;
productName = LanguageSupport;
};
68E714E52E6AA2B00025610F /* ZIPFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = 68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
DCBA85852E3897BD00E88C06 /* StikImporter */ = {
isa = XCSwiftPackageProductDependency;
package = DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */;
productName = StikImporter;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = DC6F1D2F2D94EADD0071B2B6 /* Project object */;

View file

@ -1,5 +1,5 @@
{
"originHash" : "ac6355f0d394d08e98cd8bc09669552151ca717b83a414b5708b301c9ba767e4",
"originHash" : "3675a8b363ad866505239c8e2c7f6a0862ed02618f2dca478b2519372fb79c0c",
"pins" : [
{
"identity" : "codeeditorview",
@ -18,33 +18,6 @@
"revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1",
"version" : "1.8.1"
}
},
{
"identity" : "stikimporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/StephenDev0/StikImporter",
"state" : {
"revision" : "4d80fca1334493889501d5b5a9fdcbb262e11f24",
"version" : "1.0.2"
}
},
{
"identity" : "swiftui-pipify",
"kind" : "remoteSourceControl",
"location" : "https://github.com/hugeBlack/swiftui-pipify",
"state" : {
"branch" : "main",
"revision" : "a1ec2fd1781c8289bff1a8b3f664dcf21c910efb"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation",
"state" : {
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
"version" : "0.9.19"
}
}
],
"version" : 3

View file

@ -74,9 +74,6 @@
ReferencedContainer = "container:StikDebug.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../StikDebug.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View file

@ -23,6 +23,8 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>location</string>
<string>fetch</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>

View file

@ -89,7 +89,7 @@ class RunJSViewModel: ObservableObject {
self.logs.append(exception.debugDescription)
}
self.logs.append("Script Execution Completed")
self.logs.append("You are safe to close the PIP Window.")
self.logs.append("You are safe to close this window.")
}
}
@ -203,28 +203,6 @@ class RunJSViewModel: ObservableObject {
}
}
struct RunJSViewPiP: View {
@Binding var model: RunJSViewModel?
@State private var logs: [String] = []
private let timer = Timer.publish(every: 0.034, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(logs.suffix(6).indices, id: \.self) { index in
Text(logs.suffix(6)[index])
.font(.system(size: 12))
.foregroundStyle(.white)
}
}
.padding()
.onReceive(timer) { _ in
logs = model?.logs ?? []
}
.frame(width: 300, height: 150)
}
}
struct RunJSView: View {
@ObservedObject var model: RunJSViewModel

View file

@ -16,20 +16,15 @@ struct ScriptEditorView: View {
@State private var position: CodeEditor.Position = .init()
@State private var messages: Set<TextLocated<Message>> = []
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var editorTheme: Theme {
colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight
}
var body: some View {
ZStack {
ThemedBackground(style: backgroundStyle)
Color(UIColor.systemBackground)
.ignoresSafeArea()
VStack(spacing: 0) {
@ -37,7 +32,7 @@ struct ScriptEditorView: View {
text: $scriptContent,
position: $position,
messages: $messages,
language: .swift()
language: .none
)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -57,7 +52,6 @@ struct ScriptEditorView: View {
}
}
}
.preferredColorScheme(preferredScheme)
.tint(colorScheme == .dark ? .white : .black)
.toolbar(.hidden, for: .tabBar)
}

View file

@ -36,72 +36,74 @@ struct ScriptListView: View {
return scripts.filter { $0.lastPathComponent.localizedCaseInsensitiveContains(searchText) }
}
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
@Environment(\.colorScheme) private var colorScheme
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
headerCard
List {
if isPickerMode {
Section {
Button {
onSelectScript?(nil)
} label: {
Label("No Script", systemImage: "nosign")
}
}
}
if filteredScripts.isEmpty {
emptyCard
Section {
VStack(alignment: .leading, spacing: 4) {
Label(
isPickerMode ? "No scripts available" : "No scripts found",
systemImage: "doc.text.magnifyingglass"
)
.foregroundStyle(.secondary)
Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
} else {
Section {
ForEach(filteredScripts, id: \.self) { script in
scriptRow(script)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !isPickerMode {
Button(role: .destructive) {
pendingDelete = script
showDeleteConfirmation = true
} label: { Label("Delete", systemImage: "trash") }
}
}
.contextMenu {
Button { copyName(script) } label: {
Label("Copy Filename", systemImage: "doc.on.doc")
}
Button { copyPath(script) } label: {
Label("Copy Path", systemImage: "folder")
}
if !isPickerMode {
Button { saveDefaultScript(script) } label: {
Label("Set Default", systemImage: "star")
}
Divider()
Button(role: .destructive) {
pendingDelete = script
showDeleteConfirmation = true
} label: { Label("Delete", systemImage: "trash") }
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
if isBusy {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Working…")
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
}
}
.listStyle(.insetGrouped)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search scripts…"
)
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
if alertVisible {
CustomErrorView(title: alertTitle,
message: alertMessage,
onDismiss: { alertVisible = false },
messageType: alertIsSuccess ? .success : .error)
}
if justCopied {
VStack {
Spacer()
Text("Copied")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 30)
}
.animation(.easeInOut(duration: 0.25), value: justCopied)
}
}
.navigationTitle(isPickerMode ? "Choose Script" : "Scripts")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
@ -125,7 +127,7 @@ struct ScriptListView: View {
Button("Delete", role: .destructive) { deleteScript(script) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { script in
Text("Are you sure you want to delete \(script.lastPathComponent)? This cannot be undone.")
Text("Delete \(script.lastPathComponent)? This cannot be undone.")
}
.fileImporter(
isPresented: $showImporter,
@ -137,161 +139,63 @@ struct ScriptListView: View {
}
}
}
.preferredColorScheme(preferredScheme)
.overlay {
if isBusy {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Working…")
.padding(16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
if justCopied {
VStack {
Spacer()
Text("Copied")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule())
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 30)
}
.animation(.easeInOut(duration: 0.25), value: justCopied)
}
}
.alert(alertTitle, isPresented: $alertVisible) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMessage)
}
}
// MARK: - Cards
private var headerCard: some View {
VStack(spacing: 12) {
TextField("Search scripts…", text: $searchText)
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
HStack(spacing: 12) {
if isPickerMode {
WideGlassyButton(title: "None", systemImage: "nosign") {
onSelectScript?(nil)
}
WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") {
showImporter = true
}
} else {
WideGlassyButton(title: "New", systemImage: "doc.badge.plus") {
showNewFileAlert = true
}
WideGlassyButton(title: "Import", systemImage: "tray.and.arrow.down") {
showImporter = true
}
}
}
}
.padding(20)
.background(glassyBackground)
}
// MARK: - Row
@ViewBuilder
private func scriptRow(_ script: URL) -> some View {
let isDefault = defaultScriptName == script.lastPathComponent
if isPickerMode {
Button {
onSelectScript?(script)
} label: {
scriptCard(script, showDefaultStar: true, showDelete: false)
HStack {
Label(script.lastPathComponent, systemImage: "doc.text.fill")
Spacer()
if isDefault {
Image(systemName: "star.fill").foregroundStyle(.yellow).imageScale(.small)
}
}
}
.buttonStyle(.plain)
} else {
NavigationLink {
ScriptEditorView(scriptURL: script)
} label: {
scriptCard(script, showDefaultStar: true, showDelete: true)
}
.buttonStyle(.plain)
}
}
private func scriptCard(_ script: URL, showDefaultStar: Bool, showDelete: Bool) -> some View {
let isDefault = defaultScriptName == script.lastPathComponent
return HStack(spacing: 12) {
Image(systemName: "doc.text.fill")
.foregroundColor(.blue)
.imageScale(.large)
Text(script.lastPathComponent)
.font(.body.weight(.medium))
.lineLimit(1)
HStack {
Label(script.lastPathComponent, systemImage: "doc.text.fill")
Spacer()
if showDefaultStar, isDefault {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
if showDelete {
Button(role: .destructive) {
pendingDelete = script
showDeleteConfirmation = true
} label: {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(.borderless)
}
}
.padding(20)
.background(glassyBackground)
.contextMenu {
Button { copyName(script) } label: {
Label("Copy Filename", systemImage: "doc.on.doc")
}
Button { copyPath(script) } label: {
Label("Copy Path", systemImage: "folder")
}
if !isPickerMode {
Button { saveDefaultScript(script) } label: {
Label("Set Default", systemImage: "star")
if isDefault {
Image(systemName: "star.fill").foregroundStyle(.yellow).imageScale(.small)
}
}
}
}
private var emptyCard: some View {
VStack(spacing: 6) {
Label(isPickerMode ? "No scripts available" : "No scripts found",
systemImage: "doc.text.magnifyingglass")
.font(.subheadline.weight(.semibold))
Text(isPickerMode ? "Import a file or choose None." : "Tap New or Import to get started.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(40)
.frame(maxWidth: .infinity)
.background(glassyBackground)
}
private var glassyBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
gradient: Gradient(colors: overlayColors()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.opacity(0.32)
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
private func overlayColors() -> [Color] {
let colors: [Color]
switch backgroundStyle {
case .staticGradient(let palette):
colors = palette
case .animatedGradient(let palette, _):
colors = palette
case .blobs(_, let background):
colors = background
case .particles(let particle, let background):
colors = background.isEmpty ? [particle, particle.opacity(0.4)] : background
case .customGradient(let palette):
colors = palette
case .adaptiveGradient(let light, let dark):
colors = colorScheme == .dark ? dark : light
}
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.6)] }
return [Color.blue, Color.purple]
}
// MARK: - File Ops
@ -325,7 +229,6 @@ struct ScriptListView: View {
("manic", "manic.js"),
("UTM-Dolphin", "UTM-Dolphin.js")
]
for entry in bundledScripts {
if let bundleURL = Bundle.main.url(forResource: entry.resource, withExtension: "js") {
let destination = directory.appendingPathComponent(entry.filename)
@ -409,7 +312,7 @@ struct ScriptListView: View {
}
}
// MARK: - Feedback helpers
// MARK: - Feedback
private func presentError(title: String, message: String) {
alertTitle = title; alertMessage = message
@ -439,40 +342,7 @@ struct ScriptListView: View {
}
}
// MARK: - Equal-width rounded-rectangle button (centered content)
struct WideGlassyButton: View {
let title: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: systemImage)
.imageScale(.medium)
.font(.body.weight(.semibold))
Text(title)
.font(.body.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 14)
}
.frame(height: 44)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.buttonStyle(.plain)
}
}
// MARK: - Script content stubs
private let screenshotDemoScript = """
// Screenshot Demo Script

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -8,6 +8,5 @@
#include "idevice/heartbeat.h"
#include "JSSupport/JSSupport.h"
#include "idevice/ideviceinfo.h"
#include "idevice/ls.h"
#include "idevice/location_simulation.h"
#include "idevice/profiles.h"
#include "idevice/something.h"

View file

@ -15,159 +15,9 @@ private func registerAdvancedOptionsDefault() {
// Enable advanced options by default on iOS 19/26 and above
let enabled = os.majorVersion >= 19
UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled])
UserDefaults.standard.register(defaults: ["enablePiP": enabled])
UserDefaults.standard.register(defaults: [UserDefaults.Keys.txmOverride: false])
}
// MARK: - Welcome Sheet
struct WelcomeSheetView: View {
var onDismiss: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.themeExpansionManager) private var themeExpansion
private var accent: Color {
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
var body: some View {
ZStack {
// Background now comes from global BackgroundContainer
Color.clear.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
// Card container with glassy material and stroke
VStack(alignment: .leading, spacing: 16) {
// Title
Text("Welcome!")
.font(.system(.largeTitle, design: .rounded).weight(.bold))
.foregroundColor(.primary)
.padding(.top, 8)
// Intro
Text("Thanks for installing the app. This brief introduction will help you get started.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
// App description
VStack(alignment: .leading, spacing: 6) {
Label("On‑device debugger", systemImage: "bolt.shield.fill")
.foregroundColor(accent)
.font(.headline)
Text("StikDebug is an on‑device debugger designed specifically for self‑developed apps. It helps streamline testing and troubleshooting without sending any data to external servers.")
.font(.callout)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Continue button
Button(action: { onDismiss?() }) {
Text("Continue")
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(accent.contrastText())
.frame(height: 44)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(accent)
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
)
}
.padding(.top, 8)
.accessibilityIdentifier("welcome_continue_button")
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(colorScheme == .dark ? 0.15 : 0.08), radius: 12, x: 0, y: 4)
// Footer version info for consistency
HStack {
Spacer()
Text("iOS \(UIDevice.current.systemVersion)")
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
}
.padding(.top, 6)
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
}
// Inherit preferredColorScheme from BackgroundContainer (no local override)
}
}
// MARK: - AccentColor Environment Key (leave available but unused)
struct AccentColorKey: EnvironmentKey {
static let defaultValue: Color = .accentColor
}
extension EnvironmentValues {
var accentColor: Color {
get { self[AccentColorKey.self] }
set { self[AccentColorKey.self] = newValue }
}
}
// MARK: - Helper Functions and Globals
let fileManager = FileManager.default
func httpGet(_ urlString: String, result: @escaping (String?) -> Void) {
if let url = URL(string: urlString) {
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
print("Error: \(error.localizedDescription)")
result(nil)
return
}
if let data = data, let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
print("Response: \(httpResponse.statusCode)")
if let dataString = String(data: data, encoding: .utf8) {
result(dataString)
}
} else {
print("Received non-200 status code: \(httpResponse.statusCode)")
}
}
}
task.resume()
}
}
func UpdateRetrieval() -> Bool {
var ver: String {
let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
return marketingVersion
}
let urlString = "https://raw.githubusercontent.com/0-Blu/StikJIT/refs/heads/main/version.txt"
var res = false
httpGet(urlString) { result in
if let fc = result {
if ver != fc {
res = true
}
}
}
return res
UserDefaults.standard.register(defaults: ["keepAliveAudio": true])
UserDefaults.standard.register(defaults: ["keepAliveLocation": true])
}
// MARK: - DNS Checker
@ -202,19 +52,15 @@ class DNSChecker: ObservableObject {
group.notify(queue: .main) {
if self.controlIP == nil {
self.dnsError = "No internet connection."
print("Control host lookup failed, so no internet connection.")
} else if self.appleIP == nil {
self.dnsError = "Apple DNS blocked. Your network might be filtering Apple traffic."
print("Control lookup succeeded, but Apple lookup failed: likely blocked.")
} else {
self.dnsError = nil
print("DNS lookups succeeded: Apple -> \(self.appleIP!), Control -> \(self.controlIP!)")
}
}
} else {
DispatchQueue.main.async {
self.dnsError = nil
print("Not connected to WiFi; continuing without DNS check.")
}
}
}
@ -279,56 +125,23 @@ private var heartbeatPendingShowUI = true
@main
struct HeartbeatApp: App {
@AppStorage("hasLaunchedBefore") var hasLaunchedBefore: Bool = false
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@State private var showWelcomeSheet: Bool = false
@State private var show_alert = false
@State private var alert_string = ""
@State private var alert_title = ""
@StateObject private var mount = MountingProgress.shared
@StateObject private var themeExpansionManager = ThemeExpansionManager()
@Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle
@State private var shouldAttemptHeartbeatRestart = false
init() {
registerAdvancedOptionsDefault()
newVerCheck()
let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:)))!
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))!
if UserDefaults.standard.bool(forKey: "keepAliveAudio") {
BackgroundAudioManager.shared.start()
}
if UserDefaults.standard.bool(forKey: "keepAliveLocation") {
BackgroundLocationManager.shared.start()
}
if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:))),
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))) {
method_exchangeImplementations(origMethod, fixMethod)
// Initialize UIKit tint from stored accent at launch (defaults to blue until entitlements load)
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: false)
}
// Make this static so we can call it without capturing self in init
private static func updateUIKitTint(customHex: String, hasAccess: Bool) {
let color: UIColor
if hasAccess, !customHex.isEmpty, let swiftColor = Color(hex: customHex) {
color = UIColor(swiftColor)
} else {
color = .systemBlue
}
UIView.appearance().tintColor = color
}
func newVerCheck() {
let currentDate = Calendar.current.startOfDay(for: Date())
let VUA = UserDefaults.standard.object(forKey: "VersionUpdateAlert") as? Date ?? Date.distantPast
if currentDate > Calendar.current.startOfDay(for: VUA) {
if UpdateRetrieval() {
alert_title = "Update Avaliable!"
let urlString = "https://raw.githubusercontent.com/0-Blu/StikJIT/refs/heads/main/version.txt"
httpGet(urlString) { result in
if result == nil { return }
alert_string = "Update to: version \(result!)!"
show_alert = true
}
}
UserDefaults.standard.set(currentDate, forKey: "VersionUpdateAlert")
}
}
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
@ -345,13 +158,8 @@ struct HeartbeatApp: App {
}
}
private var globalAccent: Color {
themeExpansionManager.resolvedAccentColor(from: customAccentColorHex)
}
var body: some Scene {
WindowGroup {
BackgroundContainer {
MainTabView()
.onAppear {
Task {
@ -363,59 +171,18 @@ struct HeartbeatApp: App {
try await downloadFile(from: item.urlString, to: destinationURL)
} catch {
await MainActor.run {
alert_title = "An Error has Occurred"
alert_string = "[Download DDI Error]: \(error.localizedDescription)"
show_alert = true
showAlert(title: "An Error has Occurred",
message: "[Download DDI Error]: \(error.localizedDescription)",
showOk: true)
}
break
}
}
}
}
.overlay(
ZStack {
if show_alert {
CustomErrorView(
title: alert_title,
message: alert_string,
onDismiss: {
show_alert = false
},
showButton: true,
primaryButtonText: "OK"
)
}
}
)
}
.themeExpansionManager(themeExpansionManager)
// Apply global tint to all SwiftUI views in this window
.tint(globalAccent)
.onAppear {
// On first launch, present the welcome sheet.
if !hasLaunchedBefore {
showWelcomeSheet = true
}
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex,
hasAccess: themeExpansionManager.hasThemeExpansion)
}
.onChange(of: themeExpansionManager.hasThemeExpansion) { hasAccess in
HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: hasAccess)
}
.onChange(of: customAccentColorHex) { newHex in
HeartbeatApp.updateUIKitTint(customHex: newHex,
hasAccess: themeExpansionManager.hasThemeExpansion)
}
.onChange(of: scenePhase) { newPhase in
handleScenePhaseChange(newPhase)
}
.sheet(isPresented: $showWelcomeSheet) {
WelcomeSheetView {
// When the user taps "Continue", mark the app as launched.
hasLaunchedBefore = true
showWelcomeSheet = false
}
}
}
}
@ -455,7 +222,6 @@ class MountingProgress: ObservableObject {
func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
let percentage = Double(progress) / Double(total) * 100.0
print("Mounting progress: \(percentage)%")
DispatchQueue.main.async {
self.mountProgress = percentage
}
@ -477,20 +243,18 @@ class MountingProgress: ObservableObject {
self.mountingThread = nil
}
mountingThread = Thread { [weak self] in
let thread = Thread { [weak self] in
guard let self = self else { return }
let mountResult = mountPersonalDDI(
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 mountResult != 0 {
showAlert(title: "Error", message: "An Error Occured when Mounting the DDI\nError Code: \(mountResult)", showOk: true, showTryAgain: true) { shouldTryAgain in
if shouldTryAgain {
self.mount()
}
if let mountError {
showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in
if shouldTryAgain { self.mount() }
}
} else {
self.coolisMounted = true
@ -499,10 +263,10 @@ class MountingProgress: ObservableObject {
self.mountingThread = nil
}
}
mountingThread!.qualityOfService = .background
mountingThread!.name = "mounting"
mountingThread!.start()
thread.qualityOfService = .background
thread.name = "mounting"
thread.start()
mountingThread = thread
}
}
}
@ -511,13 +275,7 @@ func isPairing() -> Bool {
let pairingpath = URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path
var pairingFile: IdevicePairingFile?
let err = idevice_pairing_file_read(pairingpath, &pairingFile)
if let err {
print("Failed to read pairing file: \(err.pointee.code)")
if err.pointee.code == -9 { // InvalidHostID is -9
return false
}
return false
}
if err != nil { return false }
idevice_pairing_file_free(pairingFile)
return true
}
@ -548,7 +306,7 @@ func startHeartbeatInBackground(showErrorUI: Bool = true) {
}
do {
try JITEnableContext.shared.startHeartbeat()
print("Heartbeat started successfully")
LogManager.shared.addInfoLog("Heartbeat started successfully")
pubHeartBeat = true
DispatchQueue.main.async {
@ -561,15 +319,15 @@ func startHeartbeatInBackground(showErrorUI: Bool = true) {
} catch {
let err2 = error as NSError
let code = err2.code
print("Error: \(error.localizedDescription) (Code: \(code))")
LogManager.shared.addErrorLog("\(error.localizedDescription) (Code: \(code))")
guard showErrorUI else { return }
DispatchQueue.main.async {
if code == -9 {
do {
try FileManager.default.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
print("Removed invalid pairing file")
LogManager.shared.addInfoLog("Removed invalid pairing file")
} catch {
print("Error removing invalid pairing file: \(error)")
LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)")
}
showAlert(
@ -584,7 +342,7 @@ func startHeartbeatInBackground(showErrorUI: Bool = true) {
} else {
showAlert(
title: "Heartbeat Error",
message: "Failed to connect to Heartbeat (\(code)). Make sure Wi‑Fi and LocalDevVPN are connected and that the device is reachable. Launch the app at least once while online before trying again.",
message: "\(error.localizedDescription)\n\nMake sure Wi‑Fi and LocalDevVPN are connected and that the device is reachable.",
showOk: false,
showTryAgain: true
) { shouldTryAgain in
@ -647,70 +405,37 @@ func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) {
}
}
public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, messageType: MessageType = .error, completion: ((Bool) -> Void)? = nil) {
public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, completion: ((Bool) -> Void)? = nil) {
DispatchQueue.main.async {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = scene.windows.first?.rootViewController else {
return
}
let rootViewController = scene.windows.first?.rootViewController
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
if showTryAgain {
let customErrorView = CustomErrorView(
title: title,
message: message,
onDismiss: {
rootViewController?.presentedViewController?.dismiss(animated: true)
completion?(false)
},
showButton: true,
primaryButtonText: primaryButtonText ?? "Try Again",
onPrimaryButtonTap: {
alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in
completion?(true)
},
messageType: messageType
)
let hostingController = UIHostingController(rootView: customErrorView)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .crossDissolve
hostingController.view.backgroundColor = .clear
rootViewController?.present(hostingController, animated: true)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
completion?(false)
})
} else if showOk {
let customErrorView = CustomErrorView(
title: title,
message: message,
onDismiss: {
rootViewController?.presentedViewController?.dismiss(animated: true)
alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in
completion?(true)
},
showButton: true,
primaryButtonText: primaryButtonText ?? "OK",
onPrimaryButtonTap: {
rootViewController?.presentedViewController?.dismiss(animated: true)
completion?(true)
},
messageType: messageType
)
let hostingController = UIHostingController(rootView: customErrorView)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .crossDissolve
hostingController.view.backgroundColor = .clear
rootViewController?.present(hostingController, animated: true)
})
} else {
let customErrorView = CustomErrorView(
title: title,
message: message,
onDismiss: {
rootViewController?.presentedViewController?.dismiss(animated: true)
completion?(false)
},
showButton: false,
messageType: messageType
)
let hostingController = UIHostingController(rootView: customErrorView)
hostingController.modalPresentationStyle = .overFullScreen
hostingController.modalTransitionStyle = .crossDissolve
hostingController.view.backgroundColor = .clear
rootViewController?.present(hostingController, animated: true)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
completion?(true)
})
}
var topController = rootViewController
while let presented = topController.presentedViewController {
topController = presented
}
topController.present(alert, animated: true)
}
}

View file

@ -1,411 +0,0 @@
//
// AppTheme.swift
// StikJIT
//
// Created by Assistant on 9/12/25.
//
import SwiftUI
// MARK: - AppTheme model
enum AppTheme: String, CaseIterable, Identifiable {
case system // balanced default gradient
case darkStatic // deep charcoal blend
case neonAnimated // neon pulse
case blobs // vibrant haze
case particles // subtle celestial particles
case aurora // shifting aurora lights
case sunset // warm sunset glow
case ocean // tranquil ocean blues
case forest // lush forest canopy
case midnight // midnight horizon
case cyberwave // synthwave inspired
var id: String { rawValue }
var displayName: String {
switch self {
case .system: return "Default"
case .darkStatic: return "Obsidian"
case .neonAnimated: return "Neon Pulse"
case .blobs: return "Haze"
case .particles: return "Stardust"
case .aurora: return "Aurora"
case .sunset: return "Sunset"
case .ocean: return "Ocean"
case .forest: return "Forest"
case .midnight: return "Midnight"
case .cyberwave: return "Cyberwave"
}
}
var preferredColorScheme: ColorScheme? {
switch self {
case .system:
return nil
case .sunset, .forest:
return nil
case .darkStatic, .neonAnimated, .blobs, .particles, .aurora, .ocean, .midnight, .cyberwave:
return .dark
}
}
var backgroundStyle: BackgroundStyle {
switch self {
case .system:
return .adaptiveGradient(light: Palette.systemLightGradient,
dark: Palette.systemDarkGradient)
case .darkStatic:
return .staticGradient(colors: Palette.obsidianGradient)
case .neonAnimated:
return .animatedGradient(colors: Palette.neon, speed: 0.10)
case .blobs:
return .blobs(colors: Palette.hazeBlobs, background: Palette.hazeBackground)
case .particles:
return .particles(particle: Palette.stardustParticle, background: Palette.stardustBackground)
case .aurora:
return .animatedGradient(colors: Palette.aurora, speed: 0.08)
case .sunset:
return .staticGradient(colors: Palette.sunset)
case .ocean:
return .animatedGradient(colors: Palette.ocean, speed: 0.06)
case .forest:
return .staticGradient(colors: Palette.forest)
case .midnight:
return .particles(particle: Palette.midnightParticle, background: Palette.midnightBackground)
case .cyberwave:
return .blobs(colors: Palette.cyberwaveBlobs, background: Palette.cyberwaveBackground)
}
}
}
// MARK: - Background styles and factory
enum BackgroundStyle: Equatable {
case staticGradient(colors: [Color])
case animatedGradient(colors: [Color], speed: Double)
case blobs(colors: [Color], background: [Color])
case particles(particle: Color, background: [Color])
case customGradient(colors: [Color])
case adaptiveGradient(light: [Color], dark: [Color])
}
private struct Palette {
static let defaultGradient = hexColors("#1C1F3A", "#3E4C7C", "#1F1C2C")
static let systemLightGradient = hexColors("#F6F8FF", "#E3ECFF", "#F0F4FF")
static let systemDarkGradient = obsidianGradient
static let obsidianGradient = hexColors("#000000", "#1C1C1C", "#262626")
static let neon = hexColors("#00F5A0", "#00D9F5", "#C96BFF")
static let hazeBlobs = hexColors("#FF8BA7", "#A98BFF", "#70C8FF", "#67FFDA")
static let hazeBackground = hexColors("#141321", "#1E1C2A")
static let stardustParticle = Color(hex: "#9BD4FF") ?? .white
static let stardustBackground = hexColors("#090A1A", "#1C1F3A")
static let aurora = hexColors("#0BA360", "#3CBA92", "#8241FF")
static let sunset = hexColors("#FF5F6D", "#FFC371", "#FF9966")
static let ocean = hexColors("#0093E9", "#80D0C7", "#13547A")
static let forest = hexColors("#2F7336", "#AA3A38", "#052E03")
static let midnightParticle = Color(hex: "#9F9FFF") ?? .white
static let midnightBackground = hexColors("#0F2027", "#203A43", "#2C5364")
static let cyberwaveBlobs = hexColors("#FF0080", "#7928CA", "#2A2A72", "#00F0FF")
static let cyberwaveBackground = hexColors("#08001A", "#110032")
static func hexColors(_ hexes: String...) -> [Color] {
hexes.compactMap { Color(hex: $0) }
}
}
// MARK: - Helpers
private func staticGradientView(colors: [Color]) -> some View {
LinearGradient(
gradient: Gradient(colors: colors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
struct ThemedBackground: View {
let style: BackgroundStyle
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
private var identity: String {
style.identityKey
}
var body: some View {
Group {
switch style {
case .staticGradient(let colors):
staticGradientView(colors: colors)
.ignoresSafeArea()
case .animatedGradient(let colors, let speed):
if reduceMotion {
staticGradientView(colors: colors)
.ignoresSafeArea()
} else {
AnimatedGradientBackground(colors: colors, speed: speed)
}
case .blobs(let colors, let background):
if reduceMotion {
staticGradientView(colors: background.isEmpty ? colors : background)
.ignoresSafeArea()
} else {
BlobBackground(blobColors: colors.ensureMinimumCount(), backgroundColors: background.ensureMinimumCount())
}
case .particles(let particle, let background):
if reduceMotion {
staticGradientView(colors: background.ensureMinimumCount())
.ignoresSafeArea()
} else {
ParticleFieldBackground(particleColor: particle, backgroundColors: background.ensureMinimumCount())
}
case .customGradient(let colors):
staticGradientView(colors: colors)
.ignoresSafeArea()
case .adaptiveGradient(let light, let dark):
let colors = colorScheme == .dark ? dark : light
staticGradientView(colors: colors)
.ignoresSafeArea()
}
}
.id(identity)
}
}
// MARK: - Background container to use at app root
struct BackgroundContainer<Content: View>: View {
@AppStorage("appTheme") private var rawTheme: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: rawTheme) ?? AppTheme.system.backgroundStyle
}
private var preferredScheme: ColorScheme? {
themeExpansion?.preferredColorScheme(for: rawTheme)
}
var body: some View {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
content
}
.preferredColorScheme(preferredScheme)
}
}
// MARK: - Animated backgrounds
private struct AnimatedGradientBackground: View {
let colors: [Color]
let speed: Double
var body: some View {
TimelineView(.animation) { timeline in
let now = timeline.date.timeIntervalSinceReferenceDate
let phase = now * speed
let rotation = Angle(degrees: phase.truncatingRemainder(dividingBy: 360) * 45)
let gradientColors = colors.ensureMinimumCount()
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .top)
.ignoresSafeArea(edges: .bottom)
.background(Rectangle()
.fill(
AngularGradient(colors: gradientColors + [gradientColors.first!], center: .center, angle: .degrees(0))
)
.hueRotation(.degrees(phase.truncatingRemainder(dividingBy: 360) * 30))
.rotationEffect(rotation)
.scaleEffect(1.2)
.ignoresSafeArea()
.overlay(
LinearGradient(colors: [.black.opacity(0.25), .clear], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
))
}
}
}
private struct BlobBackground: View {
let blobColors: [Color]
let backgroundColors: [Color]
@State private var t: CGFloat = 0
var body: some View {
Canvas { ctx, size in
let blobs = max(blobColors.count, 4)
let radius = min(size.width, size.height) * 0.45
for i in 0..<blobs {
let color = blobColors[i % blobColors.count].opacity(0.32)
let p = CGFloat(i) / CGFloat(blobs)
let angle = t * (0.45 + 0.08 * CGFloat(i)) + p * .pi * 2
let r = radius * (0.38 + 0.18 * sin(t + CGFloat(i)))
let x = size.width * 0.5 + cos(angle) * r
let y = size.height * 0.5 + sin(angle * 0.92) * r * 0.72
var path = Path()
path.addEllipse(in: CGRect(x: x - 140, y: y - 140, width: 280, height: 280))
ctx.addFilter(.blur(radius: 42))
ctx.fill(path, with: .color(color))
}
}
.background(
LinearGradient(
gradient: Gradient(colors: backgroundColors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.ignoresSafeArea()
.onAppear {
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
t = .pi * 2
}
}
}
}
private struct ParticleFieldBackground: View {
struct Particle: Identifiable {
let id = UUID()
var position: CGPoint
var velocity: CGVector
var size: CGFloat
var opacity: Double
}
let particleColor: Color
let backgroundColors: [Color]
@State private var particles: [Particle] = []
private let count = 120
var body: some View {
GeometryReader { geo in
TimelineView(.animation) { _ in
ZStack {
LinearGradient(
gradient: Gradient(colors: backgroundColors.ensureMinimumCount()),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Canvas { ctx, size in
for p in particles {
var circle = Path(ellipseIn: CGRect(x: p.position.x,
y: p.position.y,
width: p.size,
height: p.size))
ctx.addFilter(.blur(radius: 1.1))
ctx.opacity = p.opacity
ctx.fill(circle, with: .color(particleColor.opacity(0.9)))
}
}
}
.onAppear {
if particles.isEmpty {
particles = (0..<count).map { _ in
Particle(
position: CGPoint(x: .random(in: 0...geo.size.width),
y: .random(in: 0...geo.size.height)),
velocity: CGVector(dx: .random(in: -0.28...0.28),
dy: .random(in: -0.28...0.28)),
size: .random(in: 1.6...3.8),
opacity: .random(in: 0.20...0.45)
)
}
}
}
.onChange(of: geo.size) { _, newSize in
particles = particles.map { p in
var np = p
np.position.x = min(max(0, np.position.x), newSize.width)
np.position.y = min(max(0, np.position.y), newSize.height)
return np
}
}
.task {
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 16_000_000)
} catch is CancellationError {
break
} catch {
break
}
guard !Task.isCancelled else { break }
var next = particles
for i in next.indices {
var p = next[i]
p.position.x += p.velocity.dx
p.position.y += p.velocity.dy
if p.position.x < 0 { p.position.x = geo.size.width }
if p.position.x > geo.size.width { p.position.x = 0 }
if p.position.y < 0 { p.position.y = geo.size.height }
if p.position.y > geo.size.height { p.position.y = 0 }
next[i] = p
}
particles = next
}
}
}
}
.ignoresSafeArea()
}
}
private extension Array where Element == Color {
func ensureMinimumCount() -> [Color] {
if isEmpty { return [.blue, .purple] }
if count == 1 { return [self[0], self[0].opacity(0.6)] }
return self
}
var identityKey: String {
map { $0.identityKey }.joined(separator: ",")
}
}
private extension BackgroundStyle {
var identityKey: String {
switch self {
case .staticGradient(let colors):
return "static:\(colors.identityKey)"
case .animatedGradient(let colors, let speed):
return "animated:\(String(format: "%.4f", speed)):\(colors.identityKey)"
case .blobs(let colors, let background):
return "blobs:\(colors.identityKey)|bg:\(background.identityKey)"
case .particles(let particle, let background):
return "particles:\(particle.identityKey)|bg:\(background.identityKey)"
case .customGradient(let colors):
return "custom:\(colors.identityKey)"
case .adaptiveGradient(let light, let dark):
return "adaptive:l=\(light.identityKey)|d=\(dark.identityKey)"
}
}
}
private extension Color {
var identityKey: String {
if let hex = toHex() {
return hex
}
// Fallback to descriptive string when hex can't be produced (e.g. dynamic colors)
return String(describing: self)
}
}

View file

@ -0,0 +1,114 @@
//
// BackgroundAudioManager.swift
// StikJIT
//
import AVFoundation
final class BackgroundAudioManager {
static let shared = BackgroundAudioManager()
private var engine = AVAudioEngine()
private var player = AVAudioPlayerNode()
private var isRunning = false
private var healthCheckTimer: Timer?
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMediaServicesReset),
name: AVAudioSession.mediaServicesWereResetNotification,
object: nil
)
}
func start() {
isRunning = true
startEngine()
startHealthCheck()
}
func stop() {
isRunning = false
healthCheckTimer?.invalidate()
healthCheckTimer = nil
player.stop()
engine.stop()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func startEngine() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, options: .mixWithOthers)
try session.setActive(true)
engine.attach(player)
let format = engine.mainMixerNode.outputFormat(forBus: 0)
engine.connect(player, to: engine.mainMixerNode, format: format)
scheduleSilence()
try engine.start()
player.play()
} catch {
LogManager.shared.addErrorLog("BackgroundAudioManager: \(error.localizedDescription)")
}
}
private func scheduleSilence() {
let format = engine.mainMixerNode.outputFormat(forBus: 0)
let frameCount = AVAudioFrameCount(format.sampleRate)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return }
buffer.frameLength = frameCount
// PCM buffer is zero-initialized — pure silence
player.scheduleBuffer(buffer, at: nil, options: .loops)
}
// Runs every 2 seconds to reclaim the session if continuous game audio
// holds it and the interruption-ended notification never fires.
private func startHealthCheck() {
let timer = Timer(timeInterval: 2, repeats: true) { [weak self] _ in
self?.recoverIfNeeded()
}
RunLoop.main.add(timer, forMode: .common)
healthCheckTimer = timer
}
private func recoverIfNeeded() {
guard isRunning, !engine.isRunning || !player.isPlaying else { return }
do {
try AVAudioSession.sharedInstance().setActive(true)
if !engine.isRunning {
try engine.start()
}
player.play()
} catch {
// Session still held by the game — will retry next tick
}
}
@objc private func handleInterruption(_ notification: Notification) {
guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue),
type == .ended,
isRunning else { return }
// Best-effort immediate resume; health check will cover failures.
try? AVAudioSession.sharedInstance().setActive(true)
if !engine.isRunning { try? engine.start() }
player.play()
}
@objc private func handleMediaServicesReset() {
guard isRunning else { return }
engine = AVAudioEngine()
player = AVAudioPlayerNode()
startEngine()
}
}

View file

@ -0,0 +1,56 @@
//
// BackgroundLocationManager.swift
// StikJIT
//
import CoreLocation
final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
static let shared = BackgroundLocationManager()
private let locationManager = CLLocationManager()
private var isRunning = false
private override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers
locationManager.distanceFilter = CLLocationDistanceMax
locationManager.allowsBackgroundLocationUpdates = true
locationManager.pausesLocationUpdatesAutomatically = false
}
func start() {
isRunning = true
switch locationManager.authorizationStatus {
case .authorizedAlways:
locationManager.startUpdatingLocation()
case .authorizedWhenInUse:
locationManager.requestAlwaysAuthorization()
case .notDetermined:
locationManager.requestAlwaysAuthorization()
default:
break
}
}
func stop() {
isRunning = false
locationManager.stopUpdatingLocation()
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard isRunning else { return }
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
manager.startUpdatingLocation()
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Location fixes may fail (e.g. no GPS indoors) — that's fine.
// The manager just needs to be running, not actually fix a location.
}
}

View file

@ -1,89 +0,0 @@
//
// Color.swift
// StikJIT
//
// Created by Stephen on 3/27/25.
//
import SwiftUI
extension Color {
func toHex() -> String? {
let components = UIColor(self).cgColor.components
let r = Float(components?[0] ?? 0)
let g = Float(components?[1] ?? 0)
let b = Float(components?[2] ?? 0)
let hex = String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255))
return "#" + hex
}
init?(hex: String) {
if hex.isEmpty || hex.count < 2 {
return nil
}
let r, g, b: CGFloat
if hex.hasPrefix("#") && hex.count >= 7 {
let start = hex.index(hex.startIndex, offsetBy: 1)
let hexColor = String(hex[start...])
if hexColor.count == 6, let hexNumber = Int(hexColor, radix: 16) {
r = CGFloat((hexNumber & 0xff0000) >> 16) / 255
g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255
b = CGFloat(hexNumber & 0x0000ff) / 255
self.init(red: r, green: g, blue: b)
return
}
}
return nil
}
}
extension Color {
static let primaryBackground = Color.black
static let cardBackground = Color.white.opacity(0.2)
// Instead of a static color, provide a function that gets the current accent color
static func dynamicAccentColor(opacity: Double = 0.8) -> Color {
let colorHex = UserDefaults.standard.string(forKey: "customAccentColor") ?? ""
if colorHex.isEmpty {
return Color.blue.opacity(opacity)
} else {
return (Color(hex: colorHex) ?? .blue).opacity(opacity)
}
}
// For backward compatibility
static var cardBackground2: Color {
return dynamicAccentColor(opacity: 0.8)
}
static let primaryText = Color.white
static let secondaryText = Color.white.opacity(0.7)
// Determine if a color is dark or light to decide text color
func isDark() -> Bool {
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
// Calculate perceived brightness using the formula for luminance
// 0.299*R + 0.587*G + 0.114*B
let brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue)
// Return true if the color is dark (brightness < 0.5)
return brightness < 0.5
}
// Returns black or white depending on the background brightness
func contrastText() -> Color {
return self.isDark() ? .white : .black
}
}

View file

@ -8,18 +8,11 @@
import Foundation
enum DeviceConnectionContext {
static var isUsingExternalDevice: Bool {
DeviceLibraryStore.shared.isUsingExternalDevice
}
static var requiresLoopbackVPN: Bool {
false
}
static var targetIPAddress: String {
if let device = DeviceLibraryStore.shared.activeDevice {
return device.ipAddress
let stored = UserDefaults.standard.string(forKey: "customTargetIP")
if let stored, !stored.isEmpty {
return stored
}
return "127.0.0.1"
return "10.7.0.1"
}
}

View file

@ -33,9 +33,10 @@ final class DeviceInfoManager: ObservableObject {
try JITEnableContext.shared.ensureHeartbeat()
} catch {
await MainActor.run {
self.error = ("Initialization Failed", self.initErrorMessage(Int32((error as NSError).code)))
self.error = ("Initialization Failed", error.localizedDescription)
self.busy = false
}
return
}
do {
let lockdownHandle = LockdownClientSendable(raw: try JITEnableContext.shared.ideviceInfoInit())
@ -47,7 +48,7 @@ final class DeviceInfoManager: ObservableObject {
}
} catch {
await MainActor.run {
self.error = ("Initialization Failed", self.initErrorMessage(Int32((error as NSError).code)))
self.error = ("Initialization Failed", error.localizedDescription)
self.busy = false
}
}
@ -107,16 +108,6 @@ final class DeviceInfoManager: ObservableObject {
initialized = false
}
private func initErrorMessage(_ code: Int32) -> String {
switch code {
case 1: return "Couldn’t read pairingFile.plist"
case 2: return "Unable to create device provider"
case 3: return "Cannot connect to lockdown service"
case 4: return "Unable to start lockdown session"
default: return "Unknown init error (\(code))"
}
}
nonisolated private static func convertToString(_ raw: Any) -> String {
switch raw {
case let s as String: return s
@ -166,64 +157,80 @@ struct DeviceInfoView: View {
}
}
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
infoCard
List {
if !isPaired {
Section {
Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Import your device's pairing file to get started.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
if !mgr.entries.isEmpty {
Section {
ForEach(filteredEntries, id: \.key) { entry in
VStack(alignment: .leading, spacing: 4) {
Text(entry.key)
.font(.subheadline.weight(.semibold))
Text(entry.value)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.vertical, 2)
.contextMenu {
Button { copyToPasteboard(entry.value) } label: {
Label("Copy Value", systemImage: "doc.on.doc")
}
Button { copyToPasteboard("\(entry.key): \(entry.value)") } label: {
Label("Copy Key & Value", systemImage: "doc.on.clipboard")
}
}
}
}
} else if !mgr.busy && isPaired {
Section {
Text("No info available").foregroundStyle(.secondary)
}
}
}
.listStyle(.insetGrouped)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search device info…"
)
.navigationTitle("Device Info")
.overlay {
if mgr.busy {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Fetching device info…")
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
if alert {
CustomErrorView(title: alertTitle,
message: alertMsg,
onDismiss: { alert = false },
messageType: alertSuccess ? .success : .error)
}
if justCopied {
VStack {
Spacer()
Text("Copied")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 30)
}
.animation(.easeInOut(duration: 0.25), value: justCopied)
}
}
.navigationTitle("Device Info")
.alert(alertTitle, isPresented: $alert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMsg)
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if isPaired {
@ -281,63 +288,13 @@ struct DeviceInfoView: View {
}
.onAppear { if isPaired { mgr.initAndLoad() } }
.onDisappear { mgr.cleanup() }
}
.preferredColorScheme(preferredScheme)
}
// MARK: - UI Sections
private var infoCard: some View {
VStack(alignment: .leading, spacing: 14) {
TextField("Search device info…", text: $searchText)
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
.padding(.bottom, 10)
if !isPaired {
VStack(alignment: .leading, spacing: 6) {
Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Import your device’s pairing file to get started.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
}
if mgr.entries.isEmpty {
Text(mgr.busy ? "Loading…" : (isPaired ? "No info available" : ""))
.foregroundColor(.secondary)
} else {
ForEach(filteredEntries, id: \.key) { entry in
VStack(alignment: .leading, spacing: 6) {
Text(entry.key).bold()
Text(entry.value)
.font(.caption.monospaced())
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.vertical, 4)
Divider()
.background(Color.white.opacity(0.12))
.onChange(of: mgr.error?.message) { _ in
if let err = mgr.error {
fail(err.title, err.message)
mgr.error = nil
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
// MARK: - Copy / Share helpers

View file

@ -1,313 +0,0 @@
//
// DeviceLibraryStore.swift
// StikJIT
//
// Created by Stephen.
//
import Foundation
struct DeviceProfileEntry: Identifiable, Codable, Equatable {
var id: UUID
var name: String
var ipAddress: String
var pairingRelativePath: String
var pairingFilename: String
var dateAdded: Date
var lastUpdated: Date
var isTXM: Bool
init(id: UUID,
name: String,
ipAddress: String,
pairingRelativePath: String,
pairingFilename: String,
dateAdded: Date,
lastUpdated: Date,
isTXM: Bool = false) {
self.id = id
self.name = name
self.ipAddress = ipAddress
self.pairingRelativePath = pairingRelativePath
self.pairingFilename = pairingFilename
self.dateAdded = dateAdded
self.lastUpdated = lastUpdated
self.isTXM = isTXM
}
enum CodingKeys: String, CodingKey {
case id, name, ipAddress, pairingRelativePath, pairingFilename, dateAdded, lastUpdated, isTXM
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
ipAddress = try container.decode(String.self, forKey: .ipAddress)
pairingRelativePath = try container.decode(String.self, forKey: .pairingRelativePath)
pairingFilename = try container.decode(String.self, forKey: .pairingFilename)
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
isTXM = try container.decodeIfPresent(Bool.self, forKey: .isTXM) ?? false
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(ipAddress, forKey: .ipAddress)
try container.encode(pairingRelativePath, forKey: .pairingRelativePath)
try container.encode(pairingFilename, forKey: .pairingFilename)
try container.encode(dateAdded, forKey: .dateAdded)
try container.encode(lastUpdated, forKey: .lastUpdated)
try container.encode(isTXM, forKey: .isTXM)
}
}
enum DeviceLibraryError: LocalizedError {
case missingPairingData
case deviceNotFound
case pairingFileUnavailable
case fileOperationFailed(String)
var errorDescription: String? {
switch self {
case .missingPairingData:
return "Select a pairing file before saving."
case .deviceNotFound:
return "The selected device could not be found."
case .pairingFileUnavailable:
return "The pairing file for this device is missing. Re-import it and try again."
case .fileOperationFailed(let reason):
return reason
}
}
}
final class DeviceLibraryStore: ObservableObject {
static let shared = DeviceLibraryStore()
@Published private(set) var devices: [DeviceProfileEntry] = []
@Published private(set) var activeDeviceID: UUID?
var activeDevice: DeviceProfileEntry? {
guard let activeDeviceID else { return nil }
return devices.first(where: { $0.id == activeDeviceID })
}
var isUsingExternalDevice: Bool {
activeDeviceID != nil
}
var defaultLocalDevice: DeviceProfileEntry {
DeviceProfileEntry(
id: localLoopbackID,
name: "This Device",
ipAddress: "10.7.0.1",
pairingRelativePath: "",
pairingFilename: "pairingFile.plist",
dateAdded: Date.distantPast,
lastUpdated: Date.distantPast
)
}
private let fileManager = FileManager.default
private let storageURL: URL
private let pairingsDirectory: URL
private let baseDirectory: URL
private let activeDeviceKey = "DeviceLibraryActiveDeviceID"
private let localLoopbackID = UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID()
private init() {
baseDirectory = URL.documentsDirectory.appendingPathComponent("DeviceLibrary", isDirectory: true)
pairingsDirectory = baseDirectory.appendingPathComponent("Pairings", isDirectory: true)
storageURL = baseDirectory.appendingPathComponent("devices.json")
createDirectoriesIfNeeded()
loadFromDisk()
if let rawValue = UserDefaults.standard.string(forKey: activeDeviceKey),
let uuid = UUID(uuidString: rawValue),
devices.contains(where: { $0.id == uuid }) {
activeDeviceID = uuid
} else {
UserDefaults.standard.removeObject(forKey: activeDeviceKey)
UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
activeDeviceID = nil
}
updateExternalDeviceFlag()
}
// MARK: - Public API
func refresh() {
loadFromDisk()
}
func addDevice(name: String,
ipAddress: String,
pairingData: Data?,
originalFilename: String?,
isTXM: Bool) throws {
guard let pairingData else {
throw DeviceLibraryError.missingPairingData
}
let id = UUID()
let now = Date()
let relativePath = try persistPairingData(pairingData, for: id)
let entry = DeviceProfileEntry(
id: id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
ipAddress: ipAddress.trimmingCharacters(in: .whitespacesAndNewlines),
pairingRelativePath: relativePath,
pairingFilename: originalFilename ?? "pairingFile.plist",
dateAdded: now,
lastUpdated: now,
isTXM: isTXM
)
devices.append(entry)
persistDevices()
}
func update(device: DeviceProfileEntry,
name: String,
ipAddress: String,
pairingData: Data?,
originalFilename: String?,
isTXM: Bool) throws {
guard !isDefaultDevice(device) else { return }
guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
throw DeviceLibraryError.deviceNotFound
}
devices[index].name = name.trimmingCharacters(in: .whitespacesAndNewlines)
devices[index].ipAddress = ipAddress.trimmingCharacters(in: .whitespacesAndNewlines)
devices[index].lastUpdated = Date()
devices[index].isTXM = isTXM
if activeDeviceID == device.id {
UserDefaults.standard.set(devices[index].ipAddress, forKey: "TunnelDeviceIP")
}
if let pairingData {
let relativePath = try persistPairingData(pairingData, for: device.id)
devices[index].pairingRelativePath = relativePath
if let originalFilename {
devices[index].pairingFilename = originalFilename
}
}
persistDevices()
}
func remove(device: DeviceProfileEntry) throws {
guard !isDefaultDevice(device) else { return }
guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
throw DeviceLibraryError.deviceNotFound
}
let relativePath = devices[index].pairingRelativePath
let storedURL = pairingsDirectory.appendingPathComponent(relativePath)
if fileManager.fileExists(atPath: storedURL.path) {
try? fileManager.removeItem(at: storedURL)
}
devices.remove(at: index)
if activeDeviceID == device.id {
clearActiveDevice()
}
persistDevices()
}
func activate(device: DeviceProfileEntry) throws {
if isDefaultDevice(device) {
clearActiveDevice()
return
}
let storedURL = pairingsDirectory.appendingPathComponent(device.pairingRelativePath)
guard fileManager.fileExists(atPath: storedURL.path) else {
throw DeviceLibraryError.pairingFileUnavailable
}
UserDefaults.standard.set(device.ipAddress, forKey: "TunnelDeviceIP")
activeDeviceID = device.id
UserDefaults.standard.set(device.id.uuidString, forKey: activeDeviceKey)
UserDefaults.standard.set(true, forKey: UserDefaults.Keys.usingExternalDevice)
persistDevices()
updateExternalDeviceFlag()
}
func clearActiveDevice() {
activeDeviceID = nil
UserDefaults.standard.removeObject(forKey: activeDeviceKey)
UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
UserDefaults.standard.removeObject(forKey: "TunnelDeviceIP")
updateExternalDeviceFlag()
}
// MARK: - Persistence
private func createDirectoriesIfNeeded() {
do {
if !fileManager.fileExists(atPath: baseDirectory.path) {
try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
}
if !fileManager.fileExists(atPath: pairingsDirectory.path) {
try fileManager.createDirectory(at: pairingsDirectory, withIntermediateDirectories: true)
}
} catch {
print("Failed to create DeviceLibrary directories: \(error)")
}
}
private func persistPairingData(_ data: Data, for id: UUID) throws -> String {
createDirectoriesIfNeeded()
let filename = "\(id.uuidString).mobiledevicepairing"
let destination = pairingsDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
do {
try data.write(to: destination, options: .atomic)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
} catch {
throw DeviceLibraryError.fileOperationFailed("Unable to store pairing file. \(error.localizedDescription)")
}
return filename
}
private func loadFromDisk() {
guard fileManager.fileExists(atPath: storageURL.path) else {
devices = []
return
}
do {
let data = try Data(contentsOf: storageURL)
let decoded = try JSONDecoder().decode([DeviceProfileEntry].self, from: data)
devices = decoded
} catch {
print("Failed to load device library: \(error)")
devices = []
}
if let activeDeviceID, !devices.contains(where: { $0.id == activeDeviceID }) {
clearActiveDevice()
}
updateExternalDeviceFlag()
}
private func persistDevices() {
do {
createDirectoriesIfNeeded()
let data = try JSONEncoder().encode(devices)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save device library: \(error)")
}
}
private func updateExternalDeviceFlag() {
UserDefaults.standard.set(isUsingExternalDevice, forKey: UserDefaults.Keys.usingExternalDevice)
}
func isDefaultDevice(_ device: DeviceProfileEntry) -> Bool {
device.id == localLoopbackID
}
}

View file

@ -8,7 +8,7 @@ import UniformTypeIdentifiers
extension FileManager {
func filePath(atPath path: String, withLength length: Int) -> String? {
guard let file = try? contentsOfDirectory(atPath: path).filter({ $0.count == length }).first else { return nil }
guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else { return nil }
return "\(path)/\(file)"
}
}
@ -23,7 +23,5 @@ extension UserDefaults {
enum Keys {
/// Forces the app to treat the current device as TXM-capable so scripts always run.
static let txmOverride = "overrideTXMForScripts"
/// Tracks whether an external device profile is currently active.
static let usingExternalDevice = "UsingExternalDevice"
}
}

View file

@ -1,10 +0,0 @@
import Foundation
enum FeatureFlags {
/// Global toggle for exposing location spoofing UI/logic.
static let isLocationSpoofingEnabled = true
/// Controls visibility of beta-quality tabs and UI.
static let showBetaTabs = true
/// Forces the Theme Expansion to be unlocked and visible everywhere.
static let alwaysUnlockThemeExpansion = true
}

View file

@ -0,0 +1,412 @@
import AppIntents
import Foundation
// MARK: - Installed App Entity
struct InstalledAppEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(
name: "Installed App",
numericFormat: "\(placeholder: .int) apps"
)
static var defaultQuery = InstalledAppQuery()
var id: String // bundle ID
var displayName: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(displayName)", subtitle: "\(id)")
}
}
struct InstalledAppQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [InstalledAppEntity] {
let allApps = (try? JITEnableContext.shared.getAppList()) ?? [:]
return identifiers.compactMap { bundleID in
guard let name = allApps[bundleID] else { return nil }
return InstalledAppEntity(id: bundleID, displayName: name)
}
}
func entities(matching string: String) async throws -> [InstalledAppEntity] {
let all = try await suggestedEntities()
guard !string.isEmpty else { return all }
let lower = string.lowercased()
return all.filter {
$0.displayName.lowercased().contains(lower) ||
$0.id.lowercased().contains(lower)
}
}
func suggestedEntities() async throws -> [InstalledAppEntity] {
await ensureHeartbeat()
let allApps = (try? JITEnableContext.shared.getAppList()) ?? [:]
return allApps.map { InstalledAppEntity(id: $0.key, displayName: $0.value) }
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
// MARK: - Running Process Entity
struct RunningProcessEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(
name: "Running Process",
numericFormat: "\(placeholder: .int) processes"
)
static var defaultQuery = RunningProcessQuery()
// Use a stable identifier (bundleID or name) so the entity survives PID changes
var id: String
var pid: Int
var displayName: String
var bundleID: String?
var displayRepresentation: DisplayRepresentation {
let subtitle: String
if let bundleID, !bundleID.isEmpty {
subtitle = "\(bundleID) — PID \(pid)"
} else {
subtitle = "PID \(pid)"
}
return DisplayRepresentation(title: "\(displayName)", subtitle: "\(subtitle)")
}
/// Resolve the current PID for this process by re-fetching the process list.
func resolveCurrentPID() -> Int? {
var err: NSError?
let entries = FetchDeviceProcessList(&err) ?? []
for item in entries {
guard let dict = item as? NSDictionary,
let pidNum = dict["pid"] as? NSNumber else { continue }
let name = dict["name"] as? String ?? ""
let bID = dict["bundleID"] as? String ?? ""
// Match by bundle ID first (most stable), then by name
if let myBundle = bundleID, !myBundle.isEmpty, bID == myBundle {
return pidNum.intValue
}
if name == displayName {
return pidNum.intValue
}
}
return nil
}
}
struct RunningProcessQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [RunningProcessEntity] {
// Always fetch fresh so PIDs are current
await ensureHeartbeat()
let all = try fetchProcessEntities()
let idSet = Set(identifiers)
return all.filter { idSet.contains($0.id) }
}
func entities(matching string: String) async throws -> [RunningProcessEntity] {
let all = try await suggestedEntities()
guard !string.isEmpty else { return all }
let lower = string.lowercased()
return all.filter {
$0.displayName.lowercased().contains(lower) ||
($0.bundleID?.lowercased().contains(lower) ?? false) ||
"\($0.pid)".contains(string)
}
}
func suggestedEntities() async throws -> [RunningProcessEntity] {
await ensureHeartbeat()
return try fetchProcessEntities()
}
private func fetchProcessEntities() throws -> [RunningProcessEntity] {
var err: NSError?
let entries = FetchDeviceProcessList(&err) ?? []
if let err { throw err }
return entries.compactMap { item -> RunningProcessEntity? in
guard let dict = item as? NSDictionary,
let pidNumber = dict["pid"] as? NSNumber else { return nil }
let pid = pidNumber.intValue
let name = dict["name"] as? String
let bundleID = dict["bundleID"] as? String
let path = dict["path"] as? String ?? ""
let displayName: String
if let name, !name.isEmpty {
displayName = name
} else if let bundleID, !bundleID.isEmpty {
displayName = bundleID
} else if let last = path.replacingOccurrences(of: "file://", with: "").split(separator: "/").last {
displayName = String(last)
} else {
displayName = "Process \(pid)"
}
// Use bundleID as stable ID if available, otherwise fall back to name
let stableID = (bundleID != nil && !bundleID!.isEmpty) ? bundleID! : displayName
return RunningProcessEntity(id: stableID, pid: pid, displayName: displayName, bundleID: bundleID)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
// MARK: - Enable JIT Intent
struct EnableJITIntent: AppIntent, ForegroundContinuableIntent {
static var title: LocalizedStringResource = "Enable JIT"
static var description = IntentDescription(
"Enables JIT compilation for an installed app using StikDebug.",
categoryName: "StikDebug"
)
static var openAppWhenRun: Bool = true
@Parameter(title: "App", description: "The app to enable JIT for",
requestValueDialog: "Which app would you like to enable JIT for?")
var app: InstalledAppEntity?
@Parameter(title: "Process ID", description: "A specific PID to attach to instead of an app")
var pid: Int?
static var parameterSummary: some ParameterSummary {
Summary("Enable JIT for \(\.$app)")
}
func perform() async throws -> some IntentResult & ReturnsValue<String> {
let bundleID = app?.id
guard bundleID != nil || pid != nil else {
return .result(value: "Select an app or provide a PID.")
}
await ensureHeartbeat()
var scriptData: Data? = nil
var scriptName: String? = nil
if let bundleID {
if let preferred = IntentScriptResolver.preferredScript(for: bundleID) {
scriptData = preferred.data
scriptName = preferred.name
}
}
var callback: DebugAppCallback? = nil
if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
let name = scriptName ?? bundleID ?? "Script"
callback = { pid, debugProxyHandle, remoteServerHandle, semaphore in
let model = RunJSViewModel(
pid: Int(pid),
debugProxy: debugProxyHandle,
remoteServer: remoteServerHandle,
semaphore: semaphore
)
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .intentJSScriptReady,
object: nil,
userInfo: ["model": model, "scriptData": sd, "scriptName": name]
)
}
DispatchQueue.global(qos: .background).async {
do { try model.runScript(data: sd, name: name) }
catch { LogManager.shared.addErrorLog("Script error: \(error.localizedDescription)") }
}
}
}
let logger: LogFunc = { message in
if let message { LogManager.shared.addInfoLog(message) }
}
let success: Bool
let target: String
if let pid {
target = "PID \(pid)"
success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
} else if let bundleID {
target = app?.displayName ?? bundleID
success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
} else {
return .result(value: "No target specified.")
}
if success {
LogManager.shared.addInfoLog("JIT enabled for \(target) via Shortcut")
return .result(value: "Successfully enabled JIT for \(target).")
} else {
LogManager.shared.addErrorLog("Failed to enable JIT for \(target) via Shortcut")
return .result(value: "Failed to enable JIT for \(target).")
}
}
}
// MARK: - Kill Process Intent
struct KillProcessIntent: AppIntent {
static var title: LocalizedStringResource = "Kill Process"
static var description = IntentDescription(
"Terminates a running process on the device using StikDebug.",
categoryName: "StikDebug"
)
static var openAppWhenRun: Bool = false
@Parameter(title: "Process", description: "The process to terminate",
requestValueDialog: "Which process would you like to kill?")
var process: RunningProcessEntity?
@Parameter(title: "Process ID", description: "A specific PID to kill instead of selecting a process")
var pid: Int?
static var parameterSummary: some ParameterSummary {
Summary("Kill \(\.$process)")
}
func perform() async throws -> some IntentResult & ReturnsValue<String> {
let targetPID: Int
let targetName: String
if let pid {
targetPID = pid
targetName = "PID \(pid)"
await ensureHeartbeat()
} else if let process {
await ensureHeartbeat()
// Always re-resolve to get the current PID — the stored one may be stale
guard let resolved = process.resolveCurrentPID() else {
return .result(value: "\(process.displayName) is no longer running.")
}
targetPID = resolved
targetName = process.displayName
} else {
return .result(value: "Select a process or provide a PID.")
}
var err: NSError?
let success = KillDeviceProcess(Int32(targetPID), &err)
if success {
LogManager.shared.addInfoLog("Killed \(targetName) via Shortcut")
return .result(value: "Successfully killed \(targetName).")
} else {
let reason = err?.localizedDescription ?? "Unknown error"
LogManager.shared.addErrorLog("Failed to kill \(targetName) via Shortcut: \(reason)")
return .result(value: "Failed to kill \(targetName): \(reason)")
}
}
}
// MARK: - Shortcuts Provider
struct StikDebugShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: EnableJITIntent(),
phrases: [
"Enable JIT for \(\.$app) with \(.applicationName)",
"Enable JIT for \(\.$app) using \(.applicationName)",
"Enable JIT for \(\.$app) in \(.applicationName)",
"\(.applicationName) enable JIT for \(\.$app)",
"\(.applicationName) enable JIT",
"Use \(.applicationName) to enable JIT for \(\.$app)",
"Use \(.applicationName) to enable JIT"
],
shortTitle: "Enable JIT",
systemImageName: "bolt.fill"
)
AppShortcut(
intent: KillProcessIntent(),
phrases: [
"Kill \(\.$process) with \(.applicationName)",
"Kill \(\.$process) using \(.applicationName)",
"Kill \(\.$process) in \(.applicationName)",
"\(.applicationName) kill \(\.$process)",
"\(.applicationName) kill process",
"Use \(.applicationName) to kill \(\.$process)",
"Use \(.applicationName) to stop \(\.$process)"
],
shortTitle: "Kill Process",
systemImageName: "xmark.circle.fill"
)
}
}
// MARK: - Shared Heartbeat Helper
func ensureHeartbeat() async {
await MainActor.run {
pubHeartBeat = false
startHeartbeatInBackground(showErrorUI: false)
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
// MARK: - Script Resolution (mirrors HomeView logic)
enum IntentScriptResolver {
static func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
if let assigned = assignedScript(for: bundleID) {
return assigned
}
return autoScript(for: bundleID)
}
private static func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
let scriptName = mapping[bundleID] else { return nil }
let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("scripts")
let scriptURL = scriptsDir.appendingPathComponent(scriptName)
guard FileManager.default.fileExists(atPath: scriptURL.path),
let data = try? Data(contentsOf: scriptURL) else { return nil }
return (data, scriptName)
}
private static func autoScript(for bundleID: String) -> (data: Data, name: String)? {
guard ProcessInfo.processInfo.hasTXM else { return nil }
guard #available(iOS 26, *) else { return nil }
let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID)
guard let appName,
let resource = autoScriptResource(for: appName) else {
return nil
}
let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("scripts")
let documentsURL = scriptsDir.appendingPathComponent(resource.fileName)
if let data = try? Data(contentsOf: documentsURL) {
return (data, resource.fileName)
}
guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"),
let data = try? Data(contentsOf: bundleURL) else {
return nil
}
return (data, resource.fileName)
}
private static func storedFavoriteName(for bundleID: String) -> String? {
let defaults = UserDefaults(suiteName: "group.com.stik.sj")
let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
return names?[bundleID]
}
private static func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
switch appName {
case "maciOS":
return ("maciOS", "maciOS.js")
case "Amethyst", "MeloNX":
return ("Amethyst-MeloNX", "Amethyst-MeloNX.js")
case "Geode":
return ("Geode", "Geode.js")
case "Manic EMU":
return ("manic", "manic.js")
case "UTM", "DolphiniOS", "Flycast":
return ("UTM-Dolphin", "UTM-Dolphin.js")
default:
return nil
}
}
}
// MARK: - Notification for JS Script UI
extension Notification.Name {
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
}

View file

@ -1,44 +0,0 @@
//
// KeychainHelper.swift
// StikDebug
//
// Created by Stephen on 7/29/25.
//
import Security
final class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
func save(password: String, forKey key: String) {
let pwData = password.data(using: .utf8)!
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key]
SecItemDelete(query as CFDictionary)
let add: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: pwData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock]
SecItemAdd(add as CFDictionary, nil)
}
func readPassword(forKey key: String) -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var out: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &out)
guard status == errSecSuccess, let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func deletePassword(forKey key: String) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key]
SecItemDelete(query as CFDictionary)
}
}

View file

@ -7,79 +7,81 @@
import Foundation
class LogManager: ObservableObject {
final class LogManager: ObservableObject {
static let shared = LogManager()
@Published var logs: [LogEntry] = []
@Published var errorCount: Int = 0
struct LogEntry: Identifiable {
let id = UUID()
struct LogEntry: Identifiable, Sendable {
let id: UUID
let timestamp: Date
let type: LogType
let message: String
enum LogType: String {
enum LogType: String, Sendable {
case info = "INFO"
case error = "ERROR"
case debug = "DEBUG"
case warning = "WARNING"
}
init(timestamp: Date, type: LogType, message: String) {
self.id = UUID()
self.timestamp = timestamp
self.type = type
self.message = message
}
}
private init() {
// Add initial system info logs
addInfoLog("StikJIT starting up")
addInfoLog("Initializing environment")
}
func addLog(message: String, type: LogEntry.LogType) {
//clean dumb stuff
var cleanMessage = message
// Clean up common prefixes that match the log type
let prefixesToRemove = [
private static let redundantPrefixes: [String] = [
"Info: ", "INFO: ", "Information: ",
"Error: ", "ERROR: ", "ERR: ",
"Debug: ", "DEBUG: ", "DBG: ",
"Warning: ", "WARN: ", "WARNING: "
]
for prefix in prefixesToRemove {
if cleanMessage.hasPrefix(prefix) {
cleanMessage = String(cleanMessage.dropFirst(prefix.count))
break
}
private init() {
addInfoLog("StikJIT starting up")
addInfoLog("Initializing environment")
}
func addLog(message: String, type: LogEntry.LogType) {
let clean = Self.redundantPrefixes
.first(where: { message.hasPrefix($0) })
.map { String(message.dropFirst($0.count)) } ?? message
DispatchQueue.main.async {
self.logs.append(LogEntry(timestamp: Date(), type: type, message: cleanMessage))
if type == .error {
self.errorCount += 1
}
// Keep log size manageable
if self.logs.count > 1000 {
self.logs.removeFirst(100)
}
self.logs.append(LogEntry(timestamp: Date(), type: type, message: clean))
if type == .error { self.errorCount += 1 }
if self.logs.count > 1000 { self.logs.removeFirst(100) }
}
}
func addInfoLog(_ message: String) {
addLog(message: message, type: .info)
func addInfoLog(_ message: String) { addLog(message: message, type: .info) }
func addErrorLog(_ message: String) { addLog(message: message, type: .error) }
func addDebugLog(_ message: String) { addLog(message: message, type: .debug) }
func addWarningLog(_ message: String) { addLog(message: message, type: .warning) }
func setLogs(_ entries: [LogEntry]) {
DispatchQueue.main.async {
self.logs = entries
self.errorCount = entries.filter { $0.type == .error }.count
}
}
func addErrorLog(_ message: String) {
addLog(message: message, type: .error)
func appendLogs(_ entries: [LogEntry], maxTotal: Int = 1000) {
DispatchQueue.main.async {
self.logs.append(contentsOf: entries)
self.errorCount += entries.filter { $0.type == .error }.count
if self.logs.count > maxTotal {
let excess = self.logs.count - maxTotal
let removed = self.logs.prefix(excess)
self.logs.removeFirst(excess)
let removedErrors = removed.filter { $0.type == .error }.count
self.errorCount = max(0, self.errorCount - removedErrors)
}
func addDebugLog(_ message: String) {
addLog(message: message, type: .debug)
}
func addWarningLog(_ message: String) {
addLog(message: message, type: .warning)
}
func clearLogs() {
@ -91,13 +93,10 @@ class LogManager: ObservableObject {
func removeOldestLogs(count: Int) {
DispatchQueue.main.async {
// Remove the oldest logs and update error count
let removedLogs = self.logs.prefix(count)
let removed = self.logs.prefix(count)
self.logs.removeFirst(count)
// Update error count by counting removed error logs
let removedErrorCount = removedLogs.filter { $0.type == .error }.count
self.errorCount = max(0, self.errorCount - removedErrorCount)
let removedErrors = removed.filter { $0.type == .error }.count
self.errorCount = max(0, self.errorCount - removedErrors)
}
}
}

View file

@ -6,8 +6,8 @@
//
import Security
typealias SecTaskRef = OpaquePointer
@_silgen_name("SecTaskCopyValueForEntitlement")
func SecTaskCopyValueForEntitlement(
_ task: SecTaskRef,
@ -21,15 +21,7 @@ func SecTaskCreateFromSelf(
) -> SecTaskRef?
func checkAppEntitlement(_ ent: String) -> Bool {
guard let task = SecTaskCreateFromSelf(nil) else {
print("Failed to create SecTask")
return false
}
guard let entitlements = SecTaskCopyValueForEntitlement(task, ent as NSString, nil) else {
print("Failed to get entitlements")
return false
}
return entitlements.boolValue != nil && entitlements.boolValue
guard let task = SecTaskCreateFromSelf(nil) else { return false }
guard let value = SecTaskCopyValueForEntitlement(task, ent as NSString, nil) else { return false }
return value.boolValue != nil && value.boolValue
}

View file

@ -25,7 +25,13 @@ final class SystemLogStream: ObservableObject {
if updateInterval < 0 { updateInterval = 0 }
flushTimer?.invalidate()
flushTimer = nil
flushImmediatelyIfNeeded()
if !isPaused && !pendingEntries.isEmpty {
if updateInterval == 0 {
flushAllPending()
} else {
scheduleFlushIfNeeded()
}
}
}
}
@ -33,6 +39,8 @@ final class SystemLogStream: ObservableObject {
private var pendingEntries: [Entry] = []
private var flushTimer: Timer?
private var retryTimer: Timer?
private var batchTimer: Timer?
private static let batchInterval: TimeInterval = 0.1
func start() {
retryTimer?.invalidate()
@ -44,6 +52,7 @@ final class SystemLogStream: ObservableObject {
isStreaming = true
isPaused = false
lastError = nil
startBatchTimer()
JITEnableContext.shared.startSyslogRelay(handler: { [weak self] line in
guard let line else { return }
@ -60,6 +69,8 @@ final class SystemLogStream: ObservableObject {
JITEnableContext.shared.stopSyslogRelay()
flushTimer?.invalidate()
flushTimer = nil
batchTimer?.invalidate()
batchTimer = nil
pendingEntries.removeAll()
retryTimer?.invalidate()
retryTimer = nil
@ -88,7 +99,11 @@ final class SystemLogStream: ObservableObject {
func resume() {
guard isPaused else { return }
isPaused = false
flushImmediatelyIfNeeded()
if updateInterval == 0 {
flushAllPending()
} else {
scheduleFlushIfNeeded()
}
}
private func handleLine(_ line: String) {
@ -101,9 +116,8 @@ final class SystemLogStream: ObservableObject {
if pendingEntries.count > maxEntries {
pendingEntries.removeFirst(pendingEntries.count - maxEntries)
}
if updateInterval == 0 && !isPaused {
flushImmediatelyIfNeeded()
} else {
// Batching is handled by batchTimer / scheduleFlushIfNeeded
if updateInterval > 0 {
scheduleFlushIfNeeded()
}
}
@ -113,10 +127,33 @@ final class SystemLogStream: ObservableObject {
isPaused = false
flushTimer?.invalidate()
flushTimer = nil
batchTimer?.invalidate()
batchTimer = nil
lastError = error?.localizedDescription ?? "System log stream stopped"
scheduleAutoRetry()
}
private func startBatchTimer() {
batchTimer?.invalidate()
batchTimer = Timer.scheduledTimer(withTimeInterval: Self.batchInterval, repeats: true) { [weak self] _ in
guard let self else { return }
guard !self.isPaused, self.updateInterval == 0, !self.pendingEntries.isEmpty else { return }
self.flushAllPending()
}
if let batchTimer {
RunLoop.main.add(batchTimer, forMode: .common)
}
}
private func flushAllPending() {
guard !pendingEntries.isEmpty else { return }
entries.append(contentsOf: pendingEntries)
pendingEntries.removeAll()
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
private func prettify(line: String) -> String {
if let range = line.range(of: ": ") {
let messagePart = line[range.upperBound...]
@ -131,32 +168,12 @@ final class SystemLogStream: ObservableObject {
.joined(separator: "\n")
}
private func appendEntry(_ entry: Entry) {
entries.append(entry)
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
private func flushImmediatelyIfNeeded() {
guard !isPaused else { return }
if updateInterval == 0 {
while !pendingEntries.isEmpty {
let next = pendingEntries.removeFirst()
appendEntry(next)
}
} else {
scheduleFlushIfNeeded()
}
}
private func scheduleFlushIfNeeded() {
guard !isPaused else { return }
guard !pendingEntries.isEmpty else { return }
guard flushTimer == nil else { return }
if updateInterval <= 0 {
flushImmediatelyIfNeeded()
guard updateInterval > 0 else {
// Live mode uses batchTimer instead
return
}
@ -164,10 +181,7 @@ final class SystemLogStream: ObservableObject {
guard let self else { return }
self.flushTimer = nil
guard !self.isPaused else { return }
if let next = self.pendingEntries.first {
self.pendingEntries.removeFirst()
self.appendEntry(next)
}
self.flushAllPending()
if !self.pendingEntries.isEmpty {
self.scheduleFlushIfNeeded()
}

View file

@ -2,21 +2,16 @@ import Foundation
enum TabConfiguration {
static let storageKey = "enabledTabIdentifiers"
static let maxSelectableTabs = 4
private static let coreIDs: [String] = ["home", "console", "scripts", "deviceinfo"]
static let maxSelectableTabs = 12
private static let coreIDs: [String] = ["home", "scripts", "tools", "deviceinfo"]
static var allowedIDs: [String] {
var ids = coreIDs
if FeatureFlags.showBetaTabs {
ids.append("profiles")
ids.append("processes")
ids.append("devicelibrary")
if FeatureFlags.isLocationSpoofingEnabled {
ids.append("location")
}
}
return ids
}
static let defaultIDs: [String] = ["home", "console", "scripts", "deviceinfo"]
static let defaultIDs: [String] = ["home", "scripts", "tools", "deviceinfo", "profiles", "processes", "location"]
static let defaultRawValue = serialize(defaultIDs)
static func sanitize(raw: String) -> [String] {

View file

@ -1,437 +0,0 @@
import Foundation
import StoreKit
import SwiftUI
enum CustomThemeStyle: String, Codable, CaseIterable, Identifiable {
case staticGradient
case animatedGradient
case blobs
case particles
var id: String { rawValue }
var displayName: String {
switch self {
case .staticGradient: return "Static Gradient"
case .animatedGradient: return "Animated Gradient"
case .blobs: return "Floating Blobs"
case .particles: return "Particle Field"
}
}
}
struct CustomTheme: Identifiable, Codable, Equatable {
var id: UUID
var name: String
var style: CustomThemeStyle
var colorHexes: [String]
var preferredColorSchemeRaw: String?
init(id: UUID = UUID(),
name: String,
style: CustomThemeStyle,
colorHexes: [String],
preferredColorScheme: ColorScheme?) {
self.id = id
self.name = name
self.style = style
self.colorHexes = colorHexes
self.preferredColorSchemeRaw = preferredColorScheme.map { $0 == .dark ? "dark" : "light" }
}
var preferredColorScheme: ColorScheme? {
switch preferredColorSchemeRaw {
case "light": return .light
case "dark": return .dark
default: return nil
}
}
var gradientColors: [Color] {
colorHexes.compactMap { Color(hex: $0) }
}
func updating(name: String? = nil,
style: CustomThemeStyle? = nil,
colors: [String]? = nil,
preferredColorScheme: ColorScheme?? = nil) -> CustomTheme {
CustomTheme(id: id,
name: name ?? self.name,
style: style ?? self.style,
colorHexes: colors ?? self.colorHexes,
preferredColorScheme: preferredColorScheme ?? self.preferredColorScheme)
}
}
// MARK: - Distribution detection (receipt-based)
enum DistributorType: String {
case appStore
case testFlight
case other
}
@MainActor
final class ThemeExpansionManager: ObservableObject {
static let productIdentifier = "SD_Theme_Expansion"
static let comingSoonMessage = "Theme Expansion is coming soon on this store."
static let unavailableMessage = "Theme Expansion isn’t available."
@Published private(set) var hasThemeExpansion = false
@Published private(set) var themeExpansionProduct: Product?
@Published private(set) var isProcessing = false
@Published var lastError: String?
@Published private(set) var customThemes: [CustomTheme] = []
// New: distribution awareness
@Published private(set) var distributor: DistributorType
var isAppStoreBuild: Bool { distributor == .appStore }
var shouldShowThemeExpansionUpsell: Bool {
guard !isForcedUnlocked else { return false }
guard isAppStoreBuild else { return false }
if let lastError, lastError == Self.unavailableMessage {
return false
}
return true
}
private var updatesTask: Task<Void, Never>?
private let isPreviewInstance: Bool
private let isForcedUnlocked: Bool
private let customThemesKey = "ThemeExpansion.CustomThemes"
init(previewUnlocked: Bool = false) {
self.isPreviewInstance = previewUnlocked
self.isForcedUnlocked = FeatureFlags.alwaysUnlockThemeExpansion
self.distributor = ThemeExpansionManager.detectDistributor()
self.hasThemeExpansion = previewUnlocked || isForcedUnlocked
loadCustomThemes()
if (previewUnlocked || isForcedUnlocked) && customThemes.isEmpty {
customThemes = [
CustomTheme(name: "Vapor Trail",
style: .animatedGradient,
colorHexes: ["#FF00E0", "#00D0FF", "#7A00FF"],
preferredColorScheme: .dark)
]
}
guard !(previewUnlocked || isForcedUnlocked) else { return }
// Only wire StoreKit listeners if this is an App Store build
if isAppStoreBuild {
updatesTask = Task { [weak self] in
guard let self else { return }
for await result in StoreKit.Transaction.updates {
await self.handle(transactionResult: result)
}
}
Task { await refreshEntitlements() }
} else {
// Non–App Store builds cannot purchase yet; keep everything locked and quiet
self.hasThemeExpansion = false
self.themeExpansionProduct = nil
self.lastError = nil
}
}
deinit {
updatesTask?.cancel()
}
// MARK: - Public API
func refreshEntitlements() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
hasThemeExpansion = true
themeExpansionProduct = nil
lastError = nil
return
}
guard isAppStoreBuild else { return } // No-op outside App Store
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let products = try await Product.products(for: [Self.productIdentifier])
themeExpansionProduct = products.first
if products.isEmpty {
#if targetEnvironment(simulator)
lastError = """
No products found for.
"""
#else
lastError = """
No products found for.
"""
#endif
}
// Recompute entitlement from current entitlements
hasThemeExpansion = await isEntitledToThemeExpansion()
} catch {
lastError = error.localizedDescription
}
}
func restorePurchases() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
lastError = nil
return
}
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
}
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
try await AppStore.sync()
hasThemeExpansion = await isEntitledToThemeExpansion()
} catch {
lastError = error.localizedDescription
}
}
func purchaseThemeExpansion() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
lastError = nil
hasThemeExpansion = true
return
}
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
}
let product: Product
if let cached = themeExpansionProduct {
product = cached
} else {
do {
let products = try await Product.products(for: [Self.productIdentifier])
if let first = products.first {
themeExpansionProduct = first
product = first
} else {
#if targetEnvironment(simulator)
lastError = Self.unavailableMessage
#else
lastError = Self.unavailableMessage
#endif
return
}
} catch {
lastError = error.localizedDescription
return
}
}
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let result = try await product.purchase()
switch result {
case .success(let verification):
await handle(transactionResult: verification)
case .pending:
lastError = "Purchase pending. You'll be notified when it's complete."
case .userCancelled:
break
@unknown default:
lastError = "Purchase failed due to an unknown error."
}
} catch {
lastError = error.localizedDescription
}
}
func resolvedAccentColor(from hex: String) -> Color {
guard hasThemeExpansion, !hex.isEmpty, let custom = Color(hex: hex) else { return .blue }
return custom
}
func resolvedTheme(from rawValue: String) -> AppTheme {
guard let theme = AppTheme(rawValue: rawValue), hasThemeExpansion || theme == .system else {
return .system
}
return theme
}
func backgroundStyle(for identifier: String) -> BackgroundStyle {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
let colors = normalizedColors(from: custom.colorHexes)
switch custom.style {
case .staticGradient:
return .staticGradient(colors: colors)
case .animatedGradient:
return .animatedGradient(colors: colors, speed: 0.08)
case .blobs:
return .blobs(colors: colors, background: colors)
case .particles:
return .particles(particle: colors.first ?? .white, background: colors)
}
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.backgroundStyle
}
return AppTheme.system.backgroundStyle
}
func preferredColorScheme(for identifier: String) -> ColorScheme? {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
return custom.preferredColorScheme
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.preferredColorScheme
}
return nil
}
func customThemeIdentifier(for theme: CustomTheme) -> String {
"custom:\(theme.id.uuidString)"
}
func isCustomThemeIdentifier(_ identifier: String) -> Bool {
identifier.hasPrefix("custom:")
}
func customTheme(for identifier: String) -> CustomTheme? {
guard isCustomThemeIdentifier(identifier) else { return nil }
let idString = identifier.replacingOccurrences(of: "custom:", with: "")
guard let uuid = UUID(uuidString: idString) else { return nil }
return customThemes.first { $0.id == uuid }
}
func upsert(customTheme: CustomTheme) {
guard hasThemeExpansion || isPreviewInstance else { return }
var sanitized = customTheme
sanitized.colorHexes = sanitize(hexes: sanitized.colorHexes)
if let index = customThemes.firstIndex(where: { $0.id == sanitized.id }) {
customThemes[index] = sanitized
} else {
customThemes.append(sanitized)
}
saveCustomThemes()
}
func delete(customTheme: CustomTheme) {
customThemes.removeAll { $0.id == customTheme.id }
saveCustomThemes()
}
// MARK: - Persistence
private func loadCustomThemes() {
guard let data = UserDefaults.standard.data(forKey: customThemesKey),
let decoded = try? JSONDecoder().decode([CustomTheme].self, from: data) else {
return
}
customThemes = decoded
}
private func saveCustomThemes() {
guard let data = try? JSONEncoder().encode(customThemes) else { return }
UserDefaults.standard.set(data, forKey: customThemesKey)
}
private func sanitize(hexes: [String]) -> [String] {
let cleaned = hexes.filter { !$0.isEmpty }
if cleaned.count >= 2 { return cleaned }
if let first = cleaned.first {
return [first, first]
}
return ["#1C1F3A", "#3E4C7C"]
}
private func normalizedColors(from hexes: [String]) -> [Color] {
let colors = hexes.compactMap { Color(hex: $0) }
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.65)] }
return [Color.blue, Color.purple]
}
// MARK: - StoreKit plumbing
private func handle(transactionResult: VerificationResult<StoreKit.Transaction>, finishTransaction: Bool = true) async {
switch transactionResult {
case .verified(let transaction):
if transaction.productID == Self.productIdentifier {
hasThemeExpansion = (transaction.revocationDate == nil)
lastError = nil
}
if finishTransaction {
await transaction.finish()
}
case .unverified(_, let error):
lastError = error.localizedDescription
}
}
private func isEntitledToThemeExpansion() async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result, transaction.productID == Self.productIdentifier {
return transaction.revocationDate == nil
}
}
return false
}
// MARK: - Distributor detection helper
private static func detectDistributor() -> DistributorType {
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
return .other
}
let path = receiptURL.path
if FileManager.default.fileExists(atPath: path) {
// TestFlight builds use "sandboxReceipt"
return receiptURL.lastPathComponent == "sandboxReceipt" ? .testFlight : .appStore
} else {
return .other
}
}
}
#if DEBUG
extension ThemeExpansionManager {
static func previewUnlocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: true)
}
static func previewLocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: false)
}
}
#endif
// MARK: - Environment support
private struct ThemeExpansionEnvironmentKey: EnvironmentKey {
static let defaultValue: ThemeExpansionManager? = nil
}
extension EnvironmentValues {
var themeExpansionManager: ThemeExpansionManager? {
get { self[ThemeExpansionEnvironmentKey.self] }
set { self[ThemeExpansionEnvironmentKey.self] = newValue }
}
}
extension View {
func themeExpansionManager(_ manager: ThemeExpansionManager) -> some View {
environment(\.themeExpansionManager, manager)
}
}

View file

@ -18,24 +18,31 @@ func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRaw
MountingProgress.shared.progressCallback(progress: progress, total: total, context: context)
}
enum MountCheckResult {
case mounted
case notMounted
case unreachable
}
func isMounted() -> Bool {
return checkMountStatus() == .mounted
}
func checkMountStatus() -> MountCheckResult {
do {
let result = try JITEnableContext.shared.getMountedDeviceCount()
return result > 0
return result > 0 ? .mounted : .notMounted
} catch {
print("Error while getMountedDeviceCount \(error)")
return false
return .unreachable
}
}
func mountPersonalDDI(imagePath: String, trustcachePath: String, manifestPath: String) -> Int {
print("Mounting \(imagePath) \(trustcachePath) \(manifestPath)")
func mountPersonalDDI(imagePath: String, trustcachePath: String, manifestPath: String) -> String? {
do {
try JITEnableContext.shared.mountPersonalDDI(withImagePath: imagePath, trustcachePath: trustcachePath, manifestPath: manifestPath)
} catch {
print("Failed to mount ddi: \(error)")
return (error as NSError).code
LogManager.shared.addErrorLog("Failed to mount DDI: \(error.localizedDescription)")
return error.localizedDescription
}
return 0
return nil
}

View file

@ -10,13 +10,10 @@ import UIKit
struct ConsoleLogsView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accentColor) private var environmentAccentColor
@StateObject private var logManager = LogManager.shared
@StateObject private var systemLogStream = SystemLogStream()
@State private var selectedConsoleTab: ConsoleTab = .idevice
@State private var jitScrollView: ScrollViewProxy? = nil
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@State private var showingCustomAlert = false
@State private var alertMessage = ""
@State private var alertTitle = ""
@ -29,36 +26,22 @@ struct ConsoleLogsView: View {
@State private var isLoadingLogs = false
@State private var jitIsAtBottom = true
@State private var syslogIsAtBottom = true
@State private var syslogSearchText = ""
@State private var showingSyslogSpeedSelector = false
private let appLogRefreshInterval: TimeInterval = 3.0
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private let syslogIntervalOptions: [Double] = [0.0, 0.2, 0.5, 1.0, 1.5, 2.0]
private var accentColor: Color {
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var overlayOpacity: Double {
colorScheme == .dark ? 0.82 : 0.9
private var filteredSyslogEntries: [SystemLogStream.Entry] {
if syslogSearchText.isEmpty {
return systemLogStream.entries
}
let query = syslogSearchText.lowercased()
return systemLogStream.entries.filter { $0.raw.lowercased().contains(query) }
}
var body: some View {
NavigationView {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
Color(colorScheme == .dark ? .black : .white)
.opacity(overlayOpacity)
.ignoresSafeArea()
VStack(spacing: 0) {
if selectedConsoleTab == .syslog {
syslogBetaTag
}
NavigationStack {
Group {
if selectedConsoleTab == .idevice {
jitLogsPane
@ -66,107 +49,83 @@ struct ConsoleLogsView: View {
syslogLogsPane
}
}
Spacer(minLength: 0)
Group {
if selectedConsoleTab == .idevice {
jitFooter
} else {
syslogFooter
}
}
}
.padding(.top, 12)
.navigationTitle("Console")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("", selection: $selectedConsoleTab) {
Text("App").tag(ConsoleTab.idevice)
Text("System").tag(ConsoleTab.syslog)
}
.pickerStyle(.segmented)
.frame(width: 180)
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
if selectedConsoleTab == .idevice {
Button("Refresh", systemImage: "arrow.clockwise") {
Task { await loadIdeviceLogsAsync() }
}
Button("Clear", systemImage: "trash", role: .destructive) {
logManager.clearLogs()
}
Button("Copy Logs", systemImage: "doc.on.doc") {
copyJITLogs()
}
exportMenuOption
} else {
Button(syslogControlLabel, systemImage: syslogControlIcon) {
toggleSyslogPlayback()
}
Button("Clear", systemImage: "trash", role: .destructive) {
systemLogStream.clear()
}
Button("Copy Logs", systemImage: "doc.on.doc") {
copySyslogToClipboard()
}
Button("Adjust Speed", systemImage: "slider.horizontal.3") {
showingSyslogSpeedSelector = true
}
.overlay(
Group {
if showingCustomAlert {
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.all)
.overlay(
CustomErrorView(
title: alertTitle,
message: alertMessage,
onDismiss: {
showingCustomAlert = false
},
showButton: true,
primaryButtonText: "OK",
messageType: isError ? .error : .success
)
)
}
if showingSyslogSpeedSelector {
Color.black.opacity(0.45)
.ignoresSafeArea()
.onTapGesture { showingSyslogSpeedSelector = false }
.overlay(
appGlassCard {
VStack(spacing: 18) {
HStack {
Text("Syslog Speed")
.font(.title3.weight(.semibold))
Spacer()
Button {
showingSyslogSpeedSelector = false
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
Image(systemName: "ellipsis.circle")
}
.buttonStyle(.plain)
}
Text("Choose how quickly new relay entries appear.")
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 10) {
}
.alert(alertTitle, isPresented: $showingCustomAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMessage)
}
.confirmationDialog("Syslog Speed", isPresented: $showingSyslogSpeedSelector) {
ForEach(syslogIntervalOptions, id: \.self) { option in
Button {
Button(intervalLabel(for: option)) {
systemLogStream.updateInterval = option
showingSyslogSpeedSelector = false
} label: {
HStack {
Text(intervalLabel(for: option))
.font(.headline)
Spacer()
if abs(systemLogStream.updateInterval - option) < 0.01 {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(accentColor)
}
}
.padding(.horizontal, 18)
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(
GlassOvalButtonStyle(
height: 44,
strokeOpacity: abs(systemLogStream.updateInterval - option) < 0.01 ? 0.35 : 0.15
)
)
Button("Cancel", role: .cancel) { }
} message: {
Text("Choose how quickly new relay entries appear.")
}
}
}
}
.padding(.horizontal, 30)
)
}
}
)
}
.navigationViewStyle(.stack)
.preferredColorScheme(preferredScheme)
.onDisappear {
systemLogStream.stop()
}
.onChange(of: systemLogStream.lastError) { newError in
if let error = newError {
alertTitle = "Syslog Error"
alertMessage = error
isError = true
showingCustomAlert = true
systemLogStream.lastError = nil
}
}
}
private var jitLogsPane: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
LazyVStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("=== DEVICE INFORMATION ===")
.font(.system(size: 11, design: .monospaced))
@ -238,10 +197,32 @@ struct ConsoleLogsView: View {
}
private var syslogLogsPane: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Filter logs", text: $syslogSearchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if !syslogSearchText.isEmpty {
Button {
syslogSearchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(8)
.background(.bar)
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(systemLogStream.entries) { entry in
ForEach(filteredSyslogEntries) { entry in
Text(AttributedString(createSyslogAttributedString(entry)))
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
@ -266,7 +247,8 @@ struct ConsoleLogsView: View {
syslogIsAtBottom = offset > -20
}
.onChange(of: systemLogStream.entries.count) { _ in
guard syslogIsAtBottom, let lastLog = systemLogStream.entries.last else { return }
guard syslogIsAtBottom, syslogSearchText.isEmpty,
let lastLog = systemLogStream.entries.last else { return }
withAnimation {
proxy.scrollTo(lastLog.id, anchor: .bottom)
}
@ -281,198 +263,42 @@ struct ConsoleLogsView: View {
}
}
}
private var jitFooter: some View {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.imageScale(.small)
.foregroundColor(.red)
Text("\(logManager.errorCount)")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
Spacer(minLength: 8)
Button {
Task { await loadIdeviceLogsAsync() }
} label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Refresh app logs")
Button {
logManager.clearLogs()
} label: {
Image(systemName: "trash")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Clear app logs")
Button {
private func copyJITLogs() {
var logsContent = "=== DEVICE INFORMATION ===\n"
logsContent += "Version: \(UIDevice.current.systemVersion)\n"
logsContent += "Name: \(UIDevice.current.name)\n"
logsContent += "Model: \(UIDevice.current.model)\n"
logsContent += "StikJIT Version: App Version: 1.0\n\n"
logsContent += "=== LOG ENTRIES ===\n"
logsContent += logManager.logs.map {
"[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)"
}.joined(separator: "\n")
UIPasteboard.general.string = logsContent
alertTitle = "Logs Copied"
alertMessage = "Logs have been copied to clipboard."
isError = false
showingCustomAlert = true
} label: {
Image(systemName: "doc.on.doc")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Copy app logs")
exportControl
Button {
selectedConsoleTab = .syslog
} label: {
Image(systemName: "rectangle.2.swap")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Switch to syslog relay")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private var syslogFooter: some View {
VStack(spacing: 10) {
if let error = systemLogStream.lastError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.imageScale(.small)
.foregroundColor(.red)
Text("\(syslogErrorCount)")
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
Spacer(minLength: 8)
Button(action: toggleSyslogPlayback) {
Image(systemName: syslogControlIcon)
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel(syslogControlLabel)
Button(action: { systemLogStream.clear() }) {
Image(systemName: "trash")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Clear syslog entries")
Button(action: copySyslogToClipboard) {
Image(systemName: "doc.on.doc")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Copy syslog entries")
Button {
showingSyslogSpeedSelector.toggle()
} label: {
Image(systemName: "slider.horizontal.3")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Adjust syslog speed")
Button {
selectedConsoleTab = .idevice
} label: {
Image(systemName: "rectangle.2.swap")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Switch to app logs")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private var syslogBetaTag: some View {
HStack(spacing: 10) {
Label {
Text("Syslogs")
.font(.headline.weight(.semibold))
} icon: {
Image(systemName: "waveform.path.ecg")
.foregroundColor(accentColor)
}
Text("BETA")
.font(.caption2.weight(.bold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.foregroundColor(accentColor)
.background(
Capsule()
.fill(accentColor.opacity(colorScheme == .dark ? 0.22 : 0.12))
)
.overlay(
Capsule()
.stroke(accentColor.opacity(0.45), lineWidth: 1)
)
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
.transition(.opacity)
}
@ViewBuilder
private var exportControl: some View {
private var exportMenuOption: some View {
let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt")
if FileManager.default.fileExists(atPath: logURL.path) {
ShareLink(
item: logURL,
preview: SharePreview("idevice_log.txt", image: Image(systemName: "doc.text"))
) {
Image(systemName: "square.and.arrow.up")
.foregroundColor(accentColor)
Label("Export Logs", systemImage: "square.and.arrow.up")
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Export app logs")
} else {
Button {
Button("Export Logs", systemImage: "square.and.arrow.up") {
alertTitle = "Export Failed"
alertMessage = "No idevice logs found"
isError = true
showingCustomAlert = true
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundColor(accentColor)
}
.buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18))
.accessibilityLabel("Export app logs")
}
}
@ -544,7 +370,7 @@ struct ConsoleLogsView: View {
case .error:
return .red
case .debug:
return accentColor
return .blue
case .warning:
return .orange
}
@ -590,52 +416,54 @@ struct ConsoleLogsView: View {
return
}
// Do all file I/O and parsing on a background thread
let result: ([LogManager.LogEntry], Int)? = await Task.detached(priority: .userInitiated) {
do {
let logContent = try String(contentsOfFile: logPath, encoding: .utf8)
let lines = logContent.components(separatedBy: .newlines)
let maxLines = 500
let startIndex = max(0, lines.count - maxLines)
let recentLines = Array(lines[startIndex..<lines.count])
let recentLines = lines[startIndex..<lines.count]
lastProcessedLineCount = lines.count
let skipPrefixes = ["=== DEVICE INFORMATION ===", "Version:", "Name:", "Model:", "=== LOG ENTRIES ==="]
await MainActor.run {
logManager.clearLogs()
var parsed: [LogManager.LogEntry] = []
parsed.reserveCapacity(recentLines.count)
for line in recentLines {
if line.isEmpty { continue }
if skipPrefixes.contains(where: { line.contains($0) }) { continue }
if line.contains("=== DEVICE INFORMATION ===") ||
line.contains("Version:") ||
line.contains("Name:") ||
line.contains("Model:") ||
line.contains("=== LOG ENTRIES ===") {
continue
}
let type: LogManager.LogEntry.LogType
if line.contains("ERROR") || line.contains("Error") {
logManager.addErrorLog(line)
type = .error
} else if line.contains("WARNING") || line.contains("Warning") {
logManager.addWarningLog(line)
type = .warning
} else if line.contains("DEBUG") {
logManager.addDebugLog(line)
type = .debug
} else {
logManager.addInfoLog(line)
type = .info
}
parsed.append(LogManager.LogEntry(timestamp: Date(), type: type, message: line))
}
return (parsed, lines.count)
} catch {
return nil
}
}.value
await MainActor.run {
if let (entries, lineCount) = result {
lastProcessedLineCount = lineCount
logManager.setLogs(entries)
if jitIsAtBottom, let last = logManager.logs.last {
jitScrollView?.scrollTo(last.id, anchor: .bottom)
}
} else {
logManager.addErrorLog("Failed to read idevice logs")
}
} catch {
await MainActor.run {
logManager.addErrorLog("Failed to read idevice logs: \(error.localizedDescription)")
}
}
await MainActor.run {
isLoadingLogs = false
}
}
@ -657,54 +485,62 @@ struct ConsoleLogsView: View {
isLoadingLogs = true
let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path
let previousCount = lastProcessedLineCount
guard FileManager.default.fileExists(atPath: logPath) else {
isLoadingLogs = false
return
}
// Parse new lines on a background thread
let result: ([LogManager.LogEntry], Int)? = await Task.detached(priority: .userInitiated) {
do {
let logContent = try String(contentsOfFile: logPath, encoding: .utf8)
let lines = logContent.components(separatedBy: .newlines)
if lines.count > lastProcessedLineCount {
let newLines = Array(lines[lastProcessedLineCount..<lines.count])
lastProcessedLineCount = lines.count
guard lines.count > previousCount else { return ([], lines.count) }
let newLines = lines[previousCount..<lines.count]
var parsed: [LogManager.LogEntry] = []
parsed.reserveCapacity(newLines.count)
await MainActor.run {
for line in newLines {
if line.isEmpty { continue }
let type: LogManager.LogEntry.LogType
if line.contains("ERROR") || line.contains("Error") {
logManager.addErrorLog(line)
type = .error
} else if line.contains("WARNING") || line.contains("Warning") {
logManager.addWarningLog(line)
type = .warning
} else if line.contains("DEBUG") {
logManager.addDebugLog(line)
type = .debug
} else {
logManager.addInfoLog(line)
type = .info
}
parsed.append(LogManager.LogEntry(timestamp: Date(), type: type, message: line))
}
let maxLines = 500
if logManager.logs.count > maxLines {
let excessCount = logManager.logs.count - maxLines
logManager.removeOldestLogs(count: excessCount)
return (parsed, lines.count)
} catch {
return nil
}
}.value
await MainActor.run {
if let (entries, lineCount) = result {
lastProcessedLineCount = lineCount
if !entries.isEmpty {
logManager.appendLogs(entries, maxTotal: 500)
if jitIsAtBottom, let last = logManager.logs.last {
jitScrollView?.scrollTo(last.id, anchor: .bottom)
}
}
} else {
logManager.addErrorLog("Failed to read new logs")
}
} catch {
await MainActor.run {
logManager.addErrorLog("Failed to read new logs: \(error.localizedDescription)")
}
}
isLoadingLogs = false
}
}
private func stopLogCheckTimer() {
logCheckTimer?.invalidate()
@ -720,18 +556,24 @@ struct ConsoleLogsView: View {
}
private func copySyslogToClipboard() {
let content = systemLogStream.concatenatedLog()
guard !content.isEmpty else {
let entries = filteredSyslogEntries
guard !entries.isEmpty else {
alertTitle = "Export Failed"
alertMessage = "No syslog entries to copy."
alertMessage = syslogSearchText.isEmpty ? "No syslog entries to copy." : "No matching syslog entries to copy."
isError = true
showingCustomAlert = true
return
}
let content = entries.map { entry in
"[\(DateFormatter.consoleLogsFormatter.string(from: entry.timestamp))] \(entry.raw)"
}.joined(separator: "\n")
UIPasteboard.general.string = content
alertTitle = "Logs Copied"
alertMessage = "Latest syslog entries copied to clipboard."
alertMessage = syslogSearchText.isEmpty
? "Latest syslog entries copied to clipboard."
: "\(entries.count) filtered syslog entries copied to clipboard."
isError = false
showingCustomAlert = true
}
@ -752,27 +594,10 @@ struct ConsoleLogsView: View {
}
private struct GlassOvalButtonStyle: ButtonStyle {
var height: CGFloat = 36
var strokeOpacity: Double = 0.16
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 14)
.frame(height: height)
.background(.ultraThinMaterial, in: Capsule())
.overlay(
Capsule()
.stroke(.white.opacity(strokeOpacity), lineWidth: 1)
)
.shadow(color: .black.opacity(0.08), radius: configuration.isPressed ? 4 : 10, x: 0, y: configuration.isPressed ? 1 : 4)
.scaleEffect(configuration.isPressed ? 0.98 : 1)
}
}
struct ConsoleLogsView_Previews: PreviewProvider {
static var previews: some View {
ConsoleLogsView()
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
}
}

View file

@ -1,164 +0,0 @@
//
// CustomErrorView.swift
// StikJIT
//
// Created by neoarz on 3/29/25.
//
import SwiftUI
public enum MessageType {
case error
case success
case info
}
struct CustomErrorView: View {
var title: String
var message: String
var onDismiss: () -> Void
var showButton: Bool = true
@State private var opacity: Double = 0
@State private var scale: CGFloat = 0.8
var primaryButtonText: String = "OK"
var secondaryButtonText: String = "Cancel"
var onPrimaryButtonTap: (() -> Void)? = nil
var onSecondaryButtonTap: (() -> Void)? = nil
var showSecondaryButton: Bool = false
var messageType: MessageType = .error
@Environment(\.colorScheme) private var colorScheme
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
private var accentColor: Color {
if customAccentColorHex.isEmpty { return .blue }
return Color(hex: customAccentColorHex) ?? .blue
}
var body: some View {
ZStack {
Color.black.opacity(0.6)
.edgesIgnoringSafeArea(.all)
.opacity(opacity)
.onTapGesture {
if showButton {
dismissWithAnimation()
}
}
VStack(spacing: 12) {
switch messageType {
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 32))
.foregroundColor(.red.opacity(0.9))
.padding(.top, 8)
case .success:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.green.opacity(0.9))
.padding(.top, 8)
case .info:
Image(systemName: "info.circle.fill")
.font(.system(size: 32))
.foregroundColor(accentColor.opacity(0.9))
.padding(.top, 8)
}
Text(title)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(colorScheme == .dark ? .white : .black)
.multilineTextAlignment(.center)
Rectangle()
.frame(height: 1)
.foregroundColor(colorScheme == .dark ? .white.opacity(0.2) : .black.opacity(0.2))
.padding(.horizontal, 12)
Text(LocalizedStringKey(message))
.font(.system(size: 15, design: .rounded))
.foregroundColor(colorScheme == .dark ? .white.opacity(0.9) : .black.opacity(0.9))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 12)
if showButton {
VStack(spacing: 6) {
Button(action: {
dismissWithAnimation()
onPrimaryButtonTap?()
}) {
Text(primaryButtonText)
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(colorScheme == .dark ? .black : .white)
.frame(height: 38)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.white : accentColor)
)
}
if showSecondaryButton {
Button(action: {
dismissWithAnimation()
onSecondaryButtonTap?()
}) {
Text(secondaryButtonText)
.font(.system(size: 16, weight: .medium, design: .rounded))
.foregroundColor(colorScheme == .dark ? .white : .gray)
.frame(height: 38)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.gray.opacity(0.3) : Color.gray.opacity(0.15))
)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.padding(.top, 6)
} else {
Spacer()
.frame(height: 12)
}
}
.frame(width: min(UIScreen.main.bounds.width - 80, 300))
.background(
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark ?
Color(UIColor.systemGray6).opacity(0.95) :
Color(UIColor.systemGray6).opacity(0.95))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(colorScheme == .dark ?
Color.white.opacity(0.2) :
Color.black.opacity(0.1),
lineWidth: 1)
)
)
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.15), radius: 16, x: 0, y: 8)
.scaleEffect(scale)
.opacity(opacity)
}
.onAppear {
withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
opacity = 1
scale = 1
}
}
}
private func dismissWithAnimation() {
withAnimation(.easeOut(duration: 0.18)) {
opacity = 0
scale = 0.95
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
onDismiss()
}
}
}

View file

@ -1,535 +0,0 @@
//
// DeviceLibraryView.swift
// StikJIT
//
// Created by Stephen.
//
import SwiftUI
import UniformTypeIdentifiers
private struct DeviceAlert: Identifiable {
let id = UUID()
let title: String
let message: String
let isError: Bool
}
private enum DeviceEditorMode: Identifiable {
case add
case edit(DeviceProfileEntry)
var id: String {
switch self {
case .add: return "add"
case .edit(let device): return device.id.uuidString
}
}
}
struct DeviceLibraryView: View {
@StateObject private var store = DeviceLibraryStore.shared
@State private var editorMode: DeviceEditorMode?
@State private var alert: DeviceAlert?
@State private var isActivatingDevice = false
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
private var savedDevices: [DeviceProfileEntry] {
store.devices.sorted { lhs, rhs in
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private var displayedDevices: [DeviceProfileEntry] {
[store.defaultLocalDevice] + savedDevices
}
private var activeSubtitle: String {
store.activeDevice != nil
? "Currently using an external device."
: "No external device selected."
}
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
fullWidthCard {
VStack(alignment: .leading, spacing: 16) {
headerRow
activeSummaryCard
ForEach(displayedDevices) { device in
let isDefault = store.isDefaultDevice(device)
DeviceRow(device: device,
isActive: isDefault ? !store.isUsingExternalDevice : store.activeDeviceID == device.id,
isDefault: isDefault,
accentColor: accentColor,
onActivate: { activate(device: device) },
onEdit: isDefault ? nil : { editorMode = .edit(device) },
onDelete: isDefault ? nil : { delete(device: device) })
if device.id != displayedDevices.last?.id {
Divider()
}
}
footerText
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
}
.navigationTitle("Devices")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editorMode = .add
} label: {
Label("Add Device", systemImage: "plus")
}
}
}
}
.tint(accentColor)
.preferredColorScheme(preferredScheme)
.disabled(isActivatingDevice)
.sheet(item: $editorMode) { mode in
DeviceEditorSheet(mode: mode) { input in
try handleSave(mode: mode, input: input)
if case .edit(let originalDevice) = mode,
store.activeDeviceID == originalDevice.id,
input.pairingData != nil {
if let refreshedDevice = store.devices.first(where: { $0.id == originalDevice.id }) {
do {
try store.activate(device: refreshedDevice)
startHeartbeatInBackground()
alert = DeviceAlert(title: "Pairing Updated",
message: "\(refreshedDevice.name)'s pairing file was refreshed.",
isError: false)
} catch {
alert = DeviceAlert(title: "Activation Failed",
message: error.localizedDescription,
isError: true)
}
}
}
}
}
.alert(item: $alert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text("OK"))
)
}
}
private func handleSave(mode: DeviceEditorMode, input: DeviceEditorInput) throws {
switch mode {
case .add:
try store.addDevice(name: input.name,
ipAddress: input.ipAddress,
pairingData: input.pairingData,
originalFilename: input.pairingFilename,
isTXM: input.isTXM)
alert = DeviceAlert(title: "Device Saved", message: "\(input.name) was added to your library.", isError: false)
case .edit(let device):
try store.update(device: device,
name: input.name,
ipAddress: input.ipAddress,
pairingData: input.pairingData,
originalFilename: input.pairingFilename ?? device.pairingFilename,
isTXM: input.isTXM)
alert = DeviceAlert(title: "Device Updated", message: "\(input.name) has been updated.", isError: false)
}
}
private func activate(device: DeviceProfileEntry) {
guard !isActivatingDevice else { return }
isActivatingDevice = true
defer { isActivatingDevice = false }
let activatingDefault = store.isDefaultDevice(device)
do {
try store.activate(device: device)
startHeartbeatInBackground()
let title = activatingDefault ? "Local Device Active" : "Device Activated"
let message = activatingDefault
? "Switched back to debugging on this device."
: "\(device.name) is now active. The heartbeat will refresh automatically."
alert = DeviceAlert(title: title, message: message, isError: false)
} catch {
alert = DeviceAlert(title: "Activation Failed",
message: error.localizedDescription,
isError: true)
}
}
private func delete(device: DeviceProfileEntry) {
do {
try store.remove(device: device)
} catch {
alert = DeviceAlert(title: "Delete Failed", message: error.localizedDescription, isError: true)
}
}
@ViewBuilder
private func fullWidthCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
appGlassCard {
content()
}
.frame(maxWidth: .infinity)
}
private var headerRow: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Device Library")
.font(.system(.title, design: .rounded).weight(.bold))
}
Spacer()
}
Text("Manage saved targets and switch between local or remote hardware.")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var activeSummaryCard: some View {
VStack(alignment: .leading, spacing: 12) {
Label {
Text(activeSubtitle)
.font(.subheadline)
.foregroundColor(.secondary)
} icon: {
Image(systemName: store.isUsingExternalDevice ? "antenna.radiowaves.left.and.right" : "iphone")
.foregroundColor(accentColor)
}
if let active = store.activeDevice {
let relative = relativeDateFormatter.localizedString(for: active.lastUpdated, relativeTo: Date())
VStack(alignment: .leading, spacing: 4) {
Text(active.name)
.font(.headline)
Text("IP: \(active.ipAddress)")
.font(.footnote)
.foregroundColor(.secondary)
Text("Synced \(relative).")
.font(.caption)
.foregroundColor(.secondary)
}
Button {
activate(device: store.defaultLocalDevice)
} label: {
Label("Switch to This Device", systemImage: "arrow.uturn.backward")
.font(.footnote.weight(.semibold))
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.borderedProminent)
.tint(accentColor)
} else {
Text("Using the on-device pairing file. Saved devices appear below for quick switching.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.primary.opacity(0.05))
)
}
private var footerText: some View {
Text("Saved devices keep their own pairing files so you can connect without copying them manually.")
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var relativeDateFormatter: RelativeDateTimeFormatter {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter
}
}
private struct DeviceRow: View {
let device: DeviceProfileEntry
let isActive: Bool
let isDefault: Bool
let accentColor: Color
let onActivate: () -> Void
let onEdit: (() -> Void)?
let onDelete: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(device.name)
.font(.headline)
if isActive {
Label("Active", systemImage: "checkmark.circle.fill")
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(accentColor.opacity(0.15), in: Capsule())
.foregroundColor(accentColor)
}
}
if isDefault {
Text("Loopback IP: \(device.ipAddress)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Uses the pairing file already stored on this device.")
.font(.footnote)
.foregroundColor(.secondary)
} else {
Text("IP: \(device.ipAddress)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Pairing: \(device.pairingFilename)")
.font(.footnote)
.foregroundColor(.secondary)
if device.isTXM {
Label("TXM Capable", systemImage: "shield.checkerboard")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.green.opacity(0.15), in: Capsule())
.foregroundColor(.green)
} else {
Label("Non-TXM", systemImage: "xmark.shield")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15), in: Capsule())
.foregroundColor(.secondary)
}
}
}
Spacer()
if onEdit != nil || onDelete != nil {
Menu {
Button("Use Device", action: onActivate)
if let onEdit {
Button("Edit Details", action: onEdit)
}
if let onDelete {
Button(role: .destructive, action: onDelete) {
Text("Delete Device")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.title3)
.foregroundColor(.secondary)
}
}
}
Button(action: onActivate) {
let title = isActive ? "Active" : (isDefault ? "Use Loopback" : "Use This Device")
HStack {
Image(systemName: isDefault ? "iphone" : "bolt.fill")
Text(title).fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(isActive ? Color.gray.opacity(0.2) : accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundColor(isActive ? .secondary : accentColor)
}
.disabled(isActive)
}
}
}
// MARK: - Editor Sheet
private struct DeviceEditorInput {
var name: String
var ipAddress: String
var pairingData: Data?
var pairingFilename: String?
var isTXM: Bool
}
private struct DeviceEditorSheet: View {
let mode: DeviceEditorMode
let onSave: (DeviceEditorInput) throws -> Void
@Environment(\.dismiss) private var dismiss
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
@State private var name: String
@State private var ipAddress: String
@State private var pairingData: Data?
@State private var selectedFilename: String?
@State private var errorMessage: String?
@State private var showImporter = false
@State private var isTXMDevice: Bool
private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
private var requiresPairing: Bool {
if case .add = mode { return true }
return false
}
private var existingFilename: String? {
if case .edit(let device) = mode {
return device.pairingFilename
}
return nil
}
private var title: String {
switch mode {
case .add: return "New Device"
case .edit: return "Edit Device"
}
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!ipAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!requiresPairing || pairingData != nil)
}
init(mode: DeviceEditorMode, onSave: @escaping (DeviceEditorInput) throws -> Void) {
self.mode = mode
self.onSave = onSave
switch mode {
case .add:
_name = State(initialValue: "")
_ipAddress = State(initialValue: "10.7.0.1")
_isTXMDevice = State(initialValue: false)
case .edit(let device):
_name = State(initialValue: device.name)
_ipAddress = State(initialValue: device.ipAddress)
_isTXMDevice = State(initialValue: device.isTXM)
}
_selectedFilename = State(initialValue: nil)
_pairingData = State(initialValue: nil)
}
var body: some View {
NavigationStack {
Form {
Section(header: Text("Details")) {
TextField("Display Name", text: $name)
.textInputAutocapitalization(.words)
TextField("Device IP", text: $ipAddress)
.keyboardType(.numbersAndPunctuation)
Toggle(isOn: $isTXMDevice) {
Text("TXM Capable")
}
.tint(accentColor)
Text("Enable if this device includes the Trusted Execution Monitor (TXM).")
.font(.caption)
.foregroundColor(.secondary)
}
Section(header: Text("Pairing File")) {
Button {
showImporter = true
} label: {
Label("Select Pairing File", systemImage: "doc.badge")
}
.buttonStyle(.borderedProminent)
.tint(accentColor)
if let filename = selectedFilename {
Text("Selected: \(filename)")
.font(.footnote)
.foregroundColor(.secondary)
} else if let existing = existingFilename {
Text("Current: \(existing)")
.font(.footnote)
.foregroundColor(.secondary)
} else {
Text("No file selected")
.font(.footnote)
.foregroundColor(.secondary)
}
}
if let errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
}
}
}
.navigationTitle(title)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save", action: save)
.disabled(!canSave)
}
}
.fileImporter(
isPresented: $showImporter,
allowedContentTypes: [
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
.propertyList
],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing { url.stopAccessingSecurityScopedResource() }
}
do {
pairingData = try Data(contentsOf: url)
selectedFilename = url.lastPathComponent
errorMessage = nil
} catch {
errorMessage = "Failed to read file: \(error.localizedDescription)"
}
case .failure(let error):
errorMessage = error.localizedDescription
}
}
}
.tint(accentColor)
}
private func save() {
guard canSave else { return }
do {
try onSave(DeviceEditorInput(
name: name,
ipAddress: ipAddress,
pairingData: pairingData,
pairingFilename: selectedFilename ?? existingFilename,
isTXM: isTXMDevice
))
dismiss()
} catch {
errorMessage = error.localizedDescription
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@ import Combine
struct InstalledAppsListView: View {
@StateObject private var viewModel = InstalledAppsViewModel()
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")!
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") ?? .standard
@AppStorage("recentApps") private var recentApps: [String] = []
@AppStorage("favoriteApps") private var favoriteApps: [String] = [] {
@ -41,10 +41,16 @@ struct InstalledAppsListView: View {
@Environment(\.dismiss) private var dismiss
var onSelectApp: (String) -> Void
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var currentSearchBinding: Binding<String> {
Binding(
get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText },
set: {
if selectedTab == .debuggable { debuggableSearchText = $0 }
else { launchSearchText = $0 }
}
)
}
private var debuggableSearchIsActive: Bool {
!debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@ -119,8 +125,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
var title: String {
switch self {
case .debuggable: return "Debuggable"
case .launch: return "Launch Apps"
case .debuggable: return "JIT"
case .launch: return "Other"
}
}
}
@ -155,29 +161,42 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
tabbedContent
tabContent(for: selectedTab)
.transition(.opacity)
.transaction { t in t.disablesAnimations = true }
.navigationTitle("Installed Apps".localized)
.searchable(
text: currentSearchBinding,
placement: .navigationBarDrawer(displayMode: .always),
prompt: selectedTab == .debuggable
? "Search apps or bundle ID".localized
: "Search".localized
)
.toolbar {
ToolbarItem(placement: .principal) {
Picker("", selection: $selectedTab) {
ForEach(AppListTab.allCases) { tab in
Text(tab.title.localized).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(width: 220)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.fontWeight(.semibold)
}
}
.onAppear {
}
}
.overlay {
if let feedback = launchFeedback {
VStack {
Spacer()
Text(feedback.message)
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(.ultraThinMaterial)
.overlay(
Capsule()
.stroke(feedback.success ? Color.green.opacity(0.35) : Color.red.opacity(0.35), lineWidth: 1)
)
)
.padding(.horizontal, 16).padding(.vertical, 8)
.background(Capsule().fill(.ultraThinMaterial))
.foregroundStyle(feedback.success ? .green : .red)
.shadow(radius: 4)
.transition(.move(edge: .bottom).combined(with: .opacity))
@ -186,16 +205,6 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id)
}
}
.navigationTitle("Installed Apps".localized)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
.ignoresSafeArea(edges: .bottom)
}
.preferredColorScheme(preferredScheme)
.onAppear {
prefetchedBundleIDs.removeAll()
prefetchPriorityIcons()
@ -215,50 +224,6 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
.onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() }
}
// MARK: Empty State
private func emptyState(for tab: AppListTab) -> some View {
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
.foregroundStyle(.secondary)
switch tab {
case .debuggable:
Text("No Debuggable App Found".localized)
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
Text("""
StikDebug can only connect to apps with the “get-task-allow” entitlement.
Please check if the app you want to connect to is signed with a development certificate.
""".localized)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
case .launch:
Text("No Launchable Apps".localized)
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
Text("""
Once your device pairing file is imported and CoreDevice is connected, all non‑debuggable and hidden system apps will appear here.
""".localized)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
.padding(24)
.glassCard(cornerRadius: 24, strokeOpacity: 0.12)
.accessibilityElement(children: .combine)
.accessibilityLabel(tab == .debuggable ? "No debuggable apps available" : "No launchable apps available")
}
// MARK: Apps List
private func prefetchPriorityIcons(limit: Int = 32) {
@ -289,149 +254,112 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
AppIconRepository.prefetch(bundleIDs: toPrefetch)
}
@ViewBuilder
private var tabbedContent: some View {
VStack(spacing: 14) {
SegmentedControl(
titles: AppListTab.allCases.map { $0.title.localized },
selection: Binding(
get: { selectedTab.rawValue },
set: { newValue in
if let tab = AppListTab(rawValue: newValue) {
selectedTab = tab
}
}
)
)
.padding(.horizontal, 12)
tabContent(for: selectedTab)
}
}
@ViewBuilder
private func tabContent(for tab: AppListTab) -> some View {
switch tab {
case .debuggable:
ScrollView {
LazyVStack(spacing: 18, pinnedViews: []) {
sectionCard {
debuggableSearchBar
}
List {
if let error = viewModel.lastError {
sectionCard {
errorBanner(error)
Section {
Text(error).font(.footnote).foregroundStyle(.orange)
}
}
if filteredDebuggableApps.isEmpty {
sectionCard {
debuggableSearchEmptyState
if filteredDebuggableApps.isEmpty && !viewModel.isLoading {
Section {
VStack(spacing: 8) {
Image(systemName: debuggableSearchIsActive ? "text.magnifyingglass" : "magnifyingglass")
.font(.system(size: 36)).foregroundStyle(.secondary)
Text(debuggableSearchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized)
.font(.headline)
Text(debuggableSearchIsActive
? "Try a different name or bundle identifier.".localized
: "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized)
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.listRowBackground(Color.clear)
}
} else {
if !filteredFavoriteBundles.isEmpty {
sectionCard(title: String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) {
LazyVStack(spacing: 12) {
Section(String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) {
ForEach(filteredFavoriteBundles, id: \.self) { bundleID in
AppButton(
bundleID: bundleID,
appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID),
recentApps: $recentApps,
favoriteApps: $favoriteApps,
onSelectApp: onSelectApp,
sharedDefaults: sharedDefaults,
performanceMode: performanceMode
recentApps: $recentApps, favoriteApps: $favoriteApps,
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
)
}
}
}
}
if !filteredRecentBundles.isEmpty {
sectionCard(title: "Recents".localized) {
LazyVStack(spacing: 12) {
Section("Recents".localized) {
ForEach(filteredRecentBundles, id: \.self) { bundleID in
AppButton(
bundleID: bundleID,
appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID),
recentApps: $recentApps,
favoriteApps: $favoriteApps,
onSelectApp: onSelectApp,
sharedDefaults: sharedDefaults,
performanceMode: performanceMode
recentApps: $recentApps, favoriteApps: $favoriteApps,
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
)
}
}
}
}
sectionCard(title: "All Applications".localized) {
LazyVStack(spacing: 12) {
Section("All Applications".localized) {
ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in
AppButton(
bundleID: bundleID,
appName: appName,
recentApps: $recentApps,
favoriteApps: $favoriteApps,
onSelectApp: onSelectApp,
sharedDefaults: sharedDefaults,
performanceMode: performanceMode
bundleID: bundleID, appName: appName,
recentApps: $recentApps, favoriteApps: $favoriteApps,
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
)
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.listStyle(.insetGrouped)
case .launch:
ScrollView {
LazyVStack(spacing: 18, pinnedViews: []) {
sectionCard {
launchSearchBar
}
List {
if let error = viewModel.lastError {
sectionCard {
errorBanner(error)
Section {
Text(error).font(.footnote).foregroundStyle(.orange)
}
}
if filteredLaunchApps.isEmpty {
sectionCard {
Group {
if launchSearchIsActive {
launchSearchEmptyState
} else {
emptyState(for: .launch)
}
Section {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 36)).foregroundStyle(.secondary)
Text(launchSearchIsActive ? "No matches".localized : "No Apps Found".localized)
.font(.headline)
Text(launchSearchIsActive
? "Try another name or bundle identifier.".localized
: "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized)
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.listRowBackground(Color.clear)
}
} else {
sectionCard(title: "Launchable Apps".localized) {
LazyVStack(spacing: 12) {
Section("Other Apps".localized) {
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
let isPinned = pinnedSystemApps.contains(bundleID)
LaunchAppRow(
bundleID: bundleID,
appName: appName,
bundleID: bundleID, appName: appName,
isLaunching: launchingBundles.contains(bundleID),
performanceMode: performanceMode
) {
startLaunching(bundleID: bundleID)
}
) { startLaunching(bundleID: bundleID) }
.overlay(alignment: .topTrailing) {
if isPinned {
Image(systemName: "star.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.yellow)
.padding(6)
.foregroundStyle(.yellow).padding(6)
}
}
.contextMenu {
Button((isPinned ? "Remove from Home" : "Add to Home").localized, systemImage: isPinned ? "star.slash" : "star") {
Button((isPinned ? "Remove from Home" : "Add to Home").localized,
systemImage: isPinned ? "star.slash" : "star") {
toggleSystemPin(bundleID: bundleID, appName: appName)
}
Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") {
@ -451,125 +379,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
}
}
}
.listStyle(.insetGrouped)
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
}
}
private var debuggableSearchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search apps or bundle ID".localized, text: $debuggableSearchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
if debuggableSearchIsActive {
Button {
debuggableSearchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.secondary)
}
.accessibilityLabel("Clear search".localized)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.9))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
}
private var debuggableSearchEmptyState: some View {
VStack(spacing: 12) {
Image(systemName: "text.magnifyingglass")
.font(.system(size: 44, weight: .regular))
.foregroundStyle(.secondary)
Text("No matching apps".localized)
.font(.title3.weight(.semibold))
Text("Try a different name or bundle identifier.".localized)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(24)
.glassCard(cornerRadius: 20, strokeOpacity: 0.12)
}
private var launchSearchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search".localized, text: $launchSearchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
if launchSearchIsActive {
Button {
launchSearchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.secondary)
}
.accessibilityLabel("Clear search".localized)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.9))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
}
private var launchSearchEmptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 44, weight: .regular))
.foregroundStyle(.secondary)
Text("No matches".localized)
.font(.title3.weight(.semibold))
Text("Try another name or bundle identifier.".localized)
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(24)
.glassCard(cornerRadius: 20, strokeOpacity: 0.12)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.footnote)
.foregroundStyle(.orange)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.9))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
)
}
// MARK: Persistence gate (avoid redundant writes + reloads)
@ -686,8 +497,6 @@ struct AppButton: View {
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
var onSelectApp: (String) -> Void
let sharedDefaults: UserDefaults
@ -697,8 +506,6 @@ struct AppButton: View {
@State private var assignedScriptName: String?
@StateObject private var iconLoader: IconLoader
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
init(
bundleID: String,
appName: String,
@ -746,10 +553,7 @@ struct AppButton: View {
.accessibilityHidden(true)
}
}
.padding(.vertical, loadAppIconsOnJIT ? 8 : 12)
.padding(.horizontal, 12)
.background(rowBackground)
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
}
.buttonStyle(.plain)
.contextMenu {
@ -841,12 +645,6 @@ struct AppButton: View {
.accessibilityHidden(true)
}
// MARK: Row Background
private var rowBackground: some View {
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
}
// MARK: Actions
private func selectApp() {
@ -919,16 +717,12 @@ struct LaunchAppRow: View {
let isLaunching: Bool
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
let performanceMode: Bool
var launchAction: () -> Void
@StateObject private var iconLoader: IconLoader
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
init(
bundleID: String,
appName: String,
@ -968,24 +762,16 @@ struct LaunchAppRow: View {
Spacer()
if isLaunching {
ProgressView()
.controlSize(.small)
ProgressView().controlSize(.small)
} else {
Text("Launch".localized)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color.accentColor.opacity(0.18))
)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
.foregroundStyle(Color.accentColor)
}
}
.padding(.vertical, loadAppIconsOnJIT ? 8 : 12)
.padding(.horizontal, 12)
.background(rowBackground)
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
}
.buttonStyle(.plain)
.disabled(isLaunching)
@ -1028,90 +814,6 @@ struct LaunchAppRow: View {
.accessibilityHidden(true)
}
private var rowBackground: some View {
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
}
}
private struct ThemedRowBackground: View {
var performanceMode: Bool
var style: BackgroundStyle
var cornerRadius: CGFloat
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
return Group {
if performanceMode {
shape
.fill(Color(.secondarySystemBackground).opacity(0.65))
.overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1))
} else {
ZStack {
shape.fill(.ultraThinMaterial)
themedOverlay(shape: shape)
shape.stroke(Color.white.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3)
}
}
}
@ViewBuilder
private func themedOverlay(shape: RoundedRectangle) -> some View {
switch style {
case .staticGradient(let colors), .customGradient(let colors):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.opacity(0.32)
case .animatedGradient(let colors, _):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .leading,
endPoint: .trailing
)
)
.opacity(0.38)
case .blobs(let colors, _):
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(colors)),
startPoint: .top,
endPoint: .bottom
)
)
.opacity(0.40)
case .particles(let particle, _):
shape.fill(particle.opacity(0.18))
case .adaptiveGradient(let light, let dark):
let palette = colorScheme == .dark ? dark : light
shape
.fill(
LinearGradient(
gradient: Gradient(colors: normalized(palette)),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.opacity(0.32)
}
}
private func normalized(_ colors: [Color]) -> [Color] {
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.6)] }
return [Color.blue, Color.purple]
}
}
private actor IconFetchRegistry {
@ -1349,62 +1051,6 @@ final class IconLoader: ObservableObject {
}
}
// MARK: - Shared UI Bits
private struct BackgroundGradient: View {
var body: some View {
LinearGradient(
colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
private struct GlassCard: ViewModifier {
var cornerRadius: CGFloat = 20
var strokeOpacity: Double = 0.15
func body(content: Content) -> some View {
content
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.95))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color.white.opacity(strokeOpacity), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3)
}
}
private extension View {
func glassCard(
cornerRadius: CGFloat = 20,
strokeOpacity: Double = 0.15
) -> some View {
modifier(GlassCard(cornerRadius: cornerRadius, strokeOpacity: strokeOpacity))
}
// Lightweight section container to keep layout predictable and fast.
func sectionCard(
title: String? = nil,
@ViewBuilder content: () -> some View
) -> some View {
VStack(alignment: .leading, spacing: 12) {
if let title {
Text(title)
.font(.headline)
.foregroundStyle(.secondary)
.accessibilityAddTraits(.isHeader)
}
content()
}
.padding(16)
.glassCard(strokeOpacity: 0.12)
}
}
enum Haptics {
static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
static func selection() { UISelectionFeedbackGenerator().selectionChanged() }
@ -1451,50 +1097,6 @@ struct InstalledAppsListView_Previews: PreviewProvider {
}
}
private struct SegmentedControl: UIViewRepresentable {
let titles: [String]
@Binding var selection: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UISegmentedControl {
let control = UISegmentedControl(items: titles)
control.selectedSegmentIndex = selection
control.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged)
return control
}
func updateUIView(_ uiView: UISegmentedControl, context: Context) {
if uiView.numberOfSegments != titles.count {
uiView.removeAllSegments()
for (index, title) in titles.enumerated() {
uiView.insertSegment(withTitle: title, at: index, animated: false)
}
}
for (index, title) in titles.enumerated() {
uiView.setTitle(title, forSegmentAt: index)
}
if uiView.selectedSegmentIndex != selection {
uiView.selectedSegmentIndex = selection
}
}
final class Coordinator: NSObject {
var parent: SegmentedControl
init(_ parent: SegmentedControl) {
self.parent = parent
}
@objc func valueChanged(_ sender: UISegmentedControl) {
parent.selection = sender.selectedSegmentIndex
}
}
}
class InstalledAppsViewModel: ObservableObject {
@Published var debuggableApps: [String: String] = [:]
@ -1552,7 +1154,6 @@ class InstalledAppsViewModel: ObservableObject {
DispatchQueue.main.async {
self.isLoading = false
self.lastError = error.localizedDescription
print("Failed to load apps: \(error)")
}
}
}

View file

@ -19,58 +19,27 @@ extension Notification.Name {
}
struct MainTabView: View {
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers: String = TabConfiguration.defaultRawValue
@AppStorage("primaryTabSelection") private var selection: String = TabConfiguration.defaultIDs.first ?? "home"
@State private var switchObserver: Any?
@State private var detachedTab: TabDescriptor?
@State private var didSetInitialHome = false
// Update checking
@State private var showForceUpdate: Bool = false
@State private var latestVersion: String? = nil
@Environment(\.themeExpansionManager) private var themeExpansion
private var accentColor: Color {
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
private var preferredScheme: ColorScheme? {
themeExpansion?.preferredColorScheme(for: appThemeRaw)
}
private var isAppStoreBuild: Bool {
#if APPSTORE
return true
#else
return false
#endif
}
private var configurableTabs: [TabDescriptor] {
var tabs: [TabDescriptor] = [
TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) },
TabDescriptor(id: "console", title: "Console", systemImage: "terminal") { AnyView(ConsoleLogsView()) },
TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }
TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) },
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) },
TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) },
TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) }
]
if FeatureFlags.showBetaTabs {
tabs.append(TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) })
tabs.append(TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) })
tabs.append(TabDescriptor(id: "devicelibrary", title: "Devices", systemImage: "list.bullet.rectangle") { AnyView(DeviceLibraryView()) })
if FeatureFlags.isLocationSpoofingEnabled {
tabs.append(TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) })
}
}
return tabs
}
private var availableTabs: [TabDescriptor] {
configurableTabs.filter { descriptor in
descriptor.id != "location" || (!isAppStoreBuild && FeatureFlags.isLocationSpoofingEnabled && FeatureFlags.showBetaTabs)
}
configurableTabs
}
private let settingsTab = TabDescriptor(id: "settings", title: "Settings", systemImage: "gearshape.fill") {
@ -85,13 +54,21 @@ struct MainTabView: View {
}
private func ensureSelectionIsValid() {
let ids = selectedTabDescriptors.map { $0.id }
if ids.contains(selection) || selection == settingsTab.id {
let ids = displayTabs.map { $0.id }
if ids.contains(selection) {
return
}
selection = ids.first ?? settingsTab.id
}
private var displayTabs: [TabDescriptor] {
var tabs = ["home", "scripts", "tools"].compactMap { id in
configurableTabs.first(where: { $0.id == id })
}
tabs.insert(settingsTab, at: min(3, tabs.count))
return tabs
}
var body: some View {
ZStack {
// Allow global themed background to show
@ -99,19 +76,12 @@ struct MainTabView: View {
// Main tabs
TabView(selection: $selection) {
ForEach(selectedTabDescriptors) { descriptor in
ForEach(displayTabs) { descriptor in
descriptor.builder()
.tabItem { Label(descriptor.title, systemImage: descriptor.systemImage) }
.tag(descriptor.id)
}
settingsTab.builder()
.tabItem { Label(settingsTab.title, systemImage: settingsTab.systemImage) }
.tag(settingsTab.id)
}
.id((themeExpansion?.hasThemeExpansion == true) ? customAccentColorHex : "default-accent")
.tint(accentColor)
.preferredColorScheme(preferredScheme)
.onAppear {
enabledTabIdentifiers = TabConfiguration.serialize(TabConfiguration.sanitize(raw: enabledTabIdentifiers))
ensureSelectionIsValid()
@ -123,7 +93,6 @@ struct MainTabView: View {
}
didSetInitialHome = true
}
checkForUpdate()
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
guard let id = note.object as? String else { return }
if selectedTabDescriptors.contains(where: { $0.id == id }) {
@ -154,99 +123,13 @@ struct MainTabView: View {
}
}
}
if showForceUpdate {
ZStack {
Color.black.opacity(0.001).ignoresSafeArea()
appGlassCard {
VStack(spacing: 20) {
Text("Update Required")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("A new version (\(latestVersion ?? "unknown")) is available. Please update to continue using the app.")
.multilineTextAlignment(.center)
.font(.callout)
.foregroundColor(.secondary)
.padding(.horizontal)
Button(action: {
let urlString: String
if isAppStoreBuild {
urlString = "itms-apps://itunes.apple.com/app/id6744045754"
} else {
urlString = "altstore://source?url=https://StikDebug.xyz/apps.json"
}
if let url = URL(string: urlString) {
UIApplication.shared.open(url)
}
}) {
Text("Update Now")
.font(.headline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
)
.foregroundColor(.white)
}
.padding(.top, 10)
}
}
.padding(.horizontal, 40)
}
.transition(.opacity.combined(with: .scale))
.animation(.easeInOut, value: showForceUpdate)
}
}
}
// MARK: - Update Checker
private func checkForUpdate() {
guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
fetchLatestVersion { latest in
latestVersion = latest
if let latest = latest,
latest.compare(currentVersion, options: .numeric) == .orderedDescending {
DispatchQueue.main.async {
showForceUpdate = true
}
}
}
}
private func fetchLatestVersion(completion: @escaping (String?) -> Void) {
guard let url = URL(string: "https://itunes.apple.com/lookup?id=6744045754") else {
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, _, _ in
guard let data = data else {
completion(nil)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let appStoreVersion = results.first?["version"] as? String {
completion(appStoreVersion)
} else {
completion(nil)
}
} catch {
completion(nil)
}
}.resume()
}
}
struct MainTabView_Previews: PreviewProvider {
static var previews: some View {
MainTabView()
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
}
}

View file

@ -8,113 +8,31 @@
import SwiftUI
import MapKit
import UIKit
import Pipify
struct MapSelectionView: UIViewRepresentable {
@Binding var coordinate: CLLocationCoordinate2D?
func makeCoordinator() -> Coordinator {
Coordinator(self)
extension CLLocationCoordinate2D: Equatable {
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapSelectionView
init(_ parent: MapSelectionView) {
self.parent = parent
}
@objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began,
let map = gesture.view as? MKMapView
else { return }
let point = gesture.location(in: map)
parent.coordinate = map.convert(point, toCoordinateFrom: map)
}
}
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
mapView.delegate = context.coordinator
let longPress = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleLongPress(_:))
)
mapView.addGestureRecognizer(longPress)
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
uiView.removeAnnotations(uiView.annotations)
if let coord = coordinate {
let annotation = MKPointAnnotation()
annotation.coordinate = coord
uiView.addAnnotation(annotation)
let region = MKCoordinateRegion(
center: coord,
latitudinalMeters: 500,
longitudinalMeters: 500
)
uiView.setRegion(region, animated: true)
}
}
}
final class LocationSpoofingPiPState: ObservableObject {
@Published var status: String = "Idle"
@Published var coordinate: CLLocationCoordinate2D?
@Published var lastUpdated: Date?
}
struct LocationSimulationView: View {
@Environment(\.themeExpansionManager) private var themeExpansion
// Serial queue: simulate_location and clear_simulated_location share C global
// state — serialising all calls eliminates the use-after-free race.
private static let locationQueue = DispatchQueue(label: "com.stik.location-sim",
qos: .userInitiated)
@State private var coordinate: CLLocationCoordinate2D?
@State private var statusMessage: String = ""
@State private var statusIsError = false
@State private var showKeepOpenAlert = false
@State private var searchQuery = ""
@State private var searchResults: [SearchResult] = []
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
@State private var resendTimer: Timer?
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@AppStorage("enablePiP") private var enablePiP = true
@State private var pipPresented = false
@StateObject private var pipState = LocationSpoofingPiPState()
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle
}
private var isAppStoreBuild: Bool {
#if APPSTORE
return true
#else
return false
#endif
}
@State private var isBusy = false
@State private var showAlert = false
@State private var alertTitle = ""
@State private var alertMessage = ""
private var pairingFilePath: String {
let docPathUrl = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
let currentDeviceUUIDStr = UserDefaults.standard.string(forKey: "DeviceLibraryActiveDeviceID")
let pairingFileURL: URL
if let uuid = currentDeviceUUIDStr,
uuid != "00000000-0000-0000-0000-000000000001" {
pairingFileURL = docPathUrl.appendingPathComponent(
"DeviceLibrary/Pairings/\(uuid).mobiledevicepairing"
)
} else {
pairingFileURL = docPathUrl.appendingPathComponent("pairingFile.plist")
}
return pairingFileURL.path()
URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path()
}
private var pairingExists: Bool {
@ -122,241 +40,119 @@ struct LocationSimulationView: View {
}
private var deviceIP: String {
UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.1"
let stored = UserDefaults.standard.string(forKey: "customTargetIP") ?? ""
return stored.isEmpty ? "10.7.0.1" : stored
}
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
ZStack(alignment: .bottom) {
MapReader { proxy in
Map(position: $position) {
if let coordinate {
Marker("Pin", coordinate: coordinate)
.tint(.red)
}
}
.mapStyle(.standard(elevation: .realistic))
.onTapGesture { point in
if let loc = proxy.convert(point, from: .local) {
coordinate = loc
}
}
.mapControls {
MapCompass()
}
}
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
searchCard
mapCard
actionsCard
.onChange(of: coordinate) { _, new in
if let new {
position = .region(MKCoordinateRegion(center: new, latitudinalMeters: 1000, longitudinalMeters: 1000))
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
if showKeepOpenAlert {
CustomErrorView(
title: "Keep StikDebug Open",
message: "Location simulation stops if the app is backgrounded. Keep StikDebug in the foreground while testing.",
onDismiss: { showKeepOpenAlert = false },
primaryButtonText: "OK",
showSecondaryButton: false,
messageType: .info
)
.transition(.opacity.combined(with: .scale))
.zIndex(1)
VStack(spacing: 12) {
if let coord = coordinate {
Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude))
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button("Stop", action: clear)
.buttonStyle(.bordered)
.tint(.red)
.disabled(!pairingExists || isBusy)
Button("Simulate Location", action: simulate)
.buttonStyle(.borderedProminent)
.disabled(!pairingExists || isBusy)
}
} else {
Text("Tap map to drop pin")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Location Simulator")
.padding(.bottom, 24)
.padding(.horizontal, 16)
}
.navigationBarTitleDisplayMode(.inline)
.alert(alertTitle, isPresented: $showAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMessage)
}
.onDisappear {
stopResendLoop()
endBackgroundTask()
dismissPiPSession()
}
.onChange(of: enablePiP) { _, newValue in
if !newValue {
pipPresented = false
}
}
}
.pipify(isPresented: Binding(
get: { pipPresented && enablePiP },
set: { pipPresented = $0 }
)) {
LocationSpoofingPiPView(state: pipState)
}
}
private var searchCard: some View {
MaterialCard {
VStack(alignment: .leading, spacing: 12) {
Text("Pick a Location")
.font(.headline)
.foregroundColor(.primary)
searchField
if !searchResults.isEmpty {
Divider()
ForEach(searchResults) { result in
Button(action: { select(result: result) }) {
VStack(alignment: .leading, spacing: 4) {
Text(result.name)
.font(.subheadline.weight(.semibold))
Text(result.subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
if result.id != searchResults.last?.id {
Divider()
}
}
}
if !pairingExists && !isAppStoreBuild {
Label("Import a pairing file first.", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundColor(.orange)
}
if isAppStoreBuild {
Label("Location simulation is unavailable in App Store builds.", systemImage: "nosign")
.font(.caption)
.foregroundColor(.orange)
}
}
}
}
private var searchField: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search for a place", text: $searchQuery, onCommit: performSearch)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
}
.padding(12)
.background(Color(UIColor.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var mapCard: some View {
MaterialCard {
VStack(alignment: .leading, spacing: 12) {
Text("Map")
.font(.headline)
.foregroundColor(.primary)
MapSelectionView(coordinate: $coordinate)
.frame(height: 320)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
if let coord = coordinate {
Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude))
.font(.footnote)
.foregroundColor(.secondary)
} else {
Text("Long-press on the map or search above to drop a pin.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
}
private var actionsCard: some View {
MaterialCard {
VStack(alignment: .leading, spacing: 12) {
Text("Actions")
.font(.headline)
.foregroundColor(.primary)
HStack(spacing: 12) {
Button(action: simulate) {
Label("Simulate", systemImage: "location.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isAppStoreBuild || coordinate == nil || !pairingExists)
Button(action: clear) {
Label("Clear", systemImage: "xmark.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isAppStoreBuild || !pairingExists)
}
if !statusMessage.isEmpty {
Text(statusMessage)
.font(.subheadline)
.foregroundColor(statusIsError ? .red : .green)
}
Text("Device IP: \(deviceIP)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func performSearch() {
guard !searchQuery.isEmpty else { return }
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = searchQuery
MKLocalSearch(request: request).start { response, _ in
let items = response?.mapItems ?? []
searchResults = items.map { item in
SearchResult(
name: item.name ?? "Unknown",
subtitle: item.placemark.title ?? "",
item: item
)
}
}
}
private func select(result: SearchResult) {
coordinate = result.item.placemark.coordinate
statusMessage = ""
searchResults = []
searchQuery = result.name
}
private func simulate() {
guard pairingExists else {
statusMessage = "Pairing file missing."
statusIsError = true
return
}
guard let coord = coordinate else { return }
let code = simulate_location(deviceIP, coord.latitude, coord.longitude, pairingFilePath)
guard pairingExists, let coord = coordinate, !isBusy else { return }
isBusy = true
let ip = deviceIP
let path = pairingFilePath
let lat = coord.latitude
let lon = coord.longitude
Self.locationQueue.async {
let code = simulate_location(ip, lat, lon, path)
DispatchQueue.main.async {
isBusy = false
if code == 0 {
statusMessage = "Simulation running…"
statusIsError = false
showKeepOpenAlert = true
beginBackgroundTask()
startResendLoop()
recordPiPEvent(status: "Simulating…", coordinate: coord)
} else {
statusMessage = "Simulation failed (code \(code))."
statusIsError = true
stopResendLoop()
endBackgroundTask()
dismissPiPSession()
alertTitle = "Simulation Failed"
alertMessage = "Could not simulate location (error \(code)). Make sure the device is connected and the DDI is mounted."
showAlert = true
}
}
}
}
private func clear() {
guard pairingExists else { return }
let code = clear_simulated_location()
statusMessage = code == 0 ? "Cleared simulation." : "Clear failed (code \(code))."
statusIsError = code != 0
showKeepOpenAlert = false
guard pairingExists, !isBusy else { return }
isBusy = true
stopResendLoop()
endBackgroundTask()
Self.locationQueue.async {
let code = clear_simulated_location()
DispatchQueue.main.async {
isBusy = false
if code == 0 {
recordPiPEvent(status: "Simulation cleared", coordinate: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
dismissPiPSession()
}
coordinate = nil
endBackgroundTask()
} else {
dismissPiPSession()
alertTitle = "Clear Failed"
alertMessage = "Could not clear simulated location (error \(code))."
showAlert = true
}
}
}
}
private func beginBackgroundTask() {
guard backgroundTaskID == .invalid else { return }
backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "LocationSimulation") {
endBackgroundTask()
}
backgroundTaskID = UIApplication.shared.beginBackgroundTask { endBackgroundTask() }
}
private func endBackgroundTask() {
@ -368,12 +164,14 @@ struct LocationSimulationView: View {
private func startResendLoop() {
resendTimer?.invalidate()
resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
guard self.pairingExists, let coord = self.coordinate else { return }
_ = simulate_location(self.deviceIP, coord.latitude, coord.longitude, self.pairingFilePath)
self.recordPiPEvent(status: "Location refreshed", coordinate: coord)
guard let coord = coordinate else { return }
let ip = deviceIP
let path = pairingFilePath
let lat = coord.latitude
let lon = coord.longitude
Self.locationQueue.async {
_ = simulate_location(ip, lat, lon, path)
}
if let coord = coordinate {
recordPiPEvent(status: "Simulating…", coordinate: coord)
}
}
@ -381,77 +179,4 @@ struct LocationSimulationView: View {
resendTimer?.invalidate()
resendTimer = nil
}
private func recordPiPEvent(status: String, coordinate: CLLocationCoordinate2D?) {
DispatchQueue.main.async {
pipState.status = status
pipState.coordinate = coordinate
pipState.lastUpdated = Date()
pipPresented = true
}
}
private func dismissPiPSession() {
DispatchQueue.main.async {
pipPresented = false
pipState.lastUpdated = nil
pipState.coordinate = nil
}
}
}
private struct SearchResult: Identifiable {
let id = UUID()
let name: String
let subtitle: String
let item: MKMapItem
}
private struct LocationSpoofingPiPView: View {
@ObservedObject var state: LocationSpoofingPiPState
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
private var coordinateText: String? {
guard let coordinate = state.coordinate else { return nil }
return String(format: "%.5f, %.5f", coordinate.latitude, coordinate.longitude)
}
private var lastUpdatedText: String? {
guard let lastUpdated = state.lastUpdated else { return nil }
let label = Self.relativeFormatter.localizedString(for: lastUpdated, relativeTo: Date())
return "Last send \(label)"
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(state.status)
.font(.headline)
.foregroundColor(.white)
if let coordinateText {
Text(coordinateText)
.font(.subheadline.monospaced())
.foregroundColor(.white.opacity(0.9))
}
if let lastUpdatedText {
Text(lastUpdatedText)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
Spacer()
Text("Location Spoofing")
.font(.caption2)
.foregroundColor(.white.opacity(0.7))
}
.padding()
.frame(width: 280, height: 150, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.65))
)
}
}

View file

@ -9,23 +9,12 @@ import SwiftUI
struct ProcessInspectorView: View {
@StateObject private var viewModel = ProcessInspectorViewModel()
@Environment(\.themeExpansionManager) private var themeExpansion
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@State private var killCandidate: ProcessInfoEntry?
@State private var killConfirmTask: Task<Void, Never>?
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle
}
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
content
}
.navigationTitle("Process Inspector")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@ -35,6 +24,7 @@ struct ProcessInspectorView: View {
.disabled(viewModel.isRefreshing)
}
}
.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
.task {
await viewModel.startAutoRefresh()
@ -42,84 +32,34 @@ struct ProcessInspectorView: View {
.onDisappear {
viewModel.stopAutoRefresh()
}
.alert(item: $viewModel.killAlert) { info in
Alert(
title: Text(info.title),
message: Text(info.message),
dismissButton: .default(Text("OK"))
)
.alert(viewModel.killAlertTitle, isPresented: $viewModel.showKillAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(viewModel.killAlertMessage)
}
.alert(viewModel.errorAlertTitle, isPresented: $viewModel.showErrorAlert) {
Button("Try Again") { viewModel.refresh() }
Button("OK", role: .cancel) { }
} message: {
Text(viewModel.errorAlertMessage)
}
}
@ViewBuilder
private var content: some View {
if let error = viewModel.errorMessage {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 36))
.foregroundStyle(.orange)
Text(error)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button("Try Again") {
viewModel.refresh()
}
.buttonStyle(.borderedProminent)
}
.padding()
} else {
ScrollView {
VStack(spacing: 16) {
statsCard
processesCard
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 30)
}
.refreshable {
viewModel.refresh()
}
.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
}
}
private extension ProcessInspectorView {
var statsCard: some View {
MaterialCard {
VStack(alignment: .leading, spacing: 12) {
Text("Overview")
.font(.headline)
.foregroundColor(.primary)
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Total Processes")
.font(.caption)
.foregroundColor(.secondary)
List {
Section("Overview") {
LabeledContent("Total Processes") {
Text("\(viewModel.processes.count)")
.font(.title2.bold())
}
Spacer()
}
}
}
}
}
var processesCard: some View {
MaterialCard {
VStack(alignment: .leading, spacing: 12) {
Text("Processes")
.font(.headline)
.foregroundColor(.primary)
Section("Processes") {
if viewModel.filteredProcesses.isEmpty {
Text("No matching processes.")
.font(.caption)
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
} else {
LazyVStack(spacing: 0) {
ForEach(viewModel.filteredProcesses) { process in
ProcessRow(
process: process,
@ -127,19 +67,16 @@ private extension ProcessInspectorView {
isConfirming: killCandidate?.pid == process.pid,
onKillTap: { handleKillTap(for: $0) }
)
.padding(.vertical, 6)
if process.id != viewModel.filteredProcesses.last?.id {
Divider()
.background(Color.white.opacity(0.1))
}
}
}
}
}
}
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { viewModel.refresh() }
}
}
private extension ProcessInspectorView {
func handleKillTap(for process: ProcessInfoEntry) {
if killCandidate?.pid == process.pid {
killConfirmTask?.cancel()
@ -223,18 +160,16 @@ private struct ProcessRow: View {
@MainActor
final class ProcessInspectorViewModel: ObservableObject {
struct AlertInfo: Identifiable {
let id = UUID()
let title: String
let message: String
}
@Published private(set) var processes: [ProcessInfoEntry] = []
@Published var searchText: String = ""
@Published var isRefreshing = false
@Published var errorMessage: String?
@Published var showErrorAlert = false
@Published var errorAlertTitle = ""
@Published var errorAlertMessage = ""
@Published private(set) var killingPID: Int?
@Published var killAlert: AlertInfo?
@Published var showKillAlert = false
@Published var killAlertTitle = ""
@Published var killAlertMessage = ""
private var refreshTask: Task<Void, Never>?
private var killTimeoutTask: Task<Void, Never>?
@ -279,14 +214,15 @@ final class ProcessInspectorViewModel: ObservableObject {
func refresh() {
guard !isRefreshing else { return }
isRefreshing = true
errorMessage = nil
Task.detached(priority: .utility) { [weak self] in
var err: NSError?
let entries = FetchDeviceProcessList(&err) ?? []
await MainActor.run {
guard let self else { return }
if let err {
self.errorMessage = err.localizedDescription
self.errorAlertTitle = "Failed to Load Processes"
self.errorAlertMessage = err.localizedDescription
self.showErrorAlert = true
} else {
self.processes = entries.compactMap { item -> ProcessInfoEntry? in
guard let dict = item as? NSDictionary else { return nil }
@ -301,7 +237,9 @@ final class ProcessInspectorViewModel: ObservableObject {
func kill(process: ProcessInfoEntry) {
guard killingPID == nil else {
killAlert = AlertInfo(title: "Busy", message: "Already terminating PID \(killingPID!).")
killAlertTitle = "Busy"
killAlertMessage = "Already terminating PID \(killingPID!)."
showKillAlert = true
return
}
let targetPID = process.pid
@ -313,10 +251,9 @@ final class ProcessInspectorViewModel: ObservableObject {
guard let self else { return }
if self.killingPID == targetPID {
self.killingPID = nil
self.killAlert = AlertInfo(
title: "Kill Timed Out",
message: "Could not confirm termination for PID \(targetPID). Try again."
)
self.killAlertTitle = "Kill Timed Out"
self.killAlertMessage = "Could not confirm termination for PID \(targetPID). Try again."
self.showKillAlert = true
}
}
}
@ -330,16 +267,14 @@ final class ProcessInspectorViewModel: ObservableObject {
guard self.killingPID == targetPID else { return }
self.killingPID = nil
if success {
self.killAlert = AlertInfo(
title: "Process Terminated",
message: "PID \(targetPID) was terminated."
)
self.killAlertTitle = "Process Terminated"
self.killAlertMessage = "PID \(targetPID) was terminated."
self.showKillAlert = true
self.refresh()
} else {
self.killAlert = AlertInfo(
title: "Kill Failed",
message: err?.localizedDescription ?? "Unknown error"
)
self.killAlertTitle = "Kill Failed"
self.killAlertMessage = err?.localizedDescription ?? "Unknown error"
self.showKillAlert = true
}
}
}

View file

@ -20,6 +20,7 @@ class Profile: ObservableObject {
@Published var uuid: String
@Published var expirationDate: Date? = nil
@Published var plistDict: [String:Any]? = nil
var decodeError: String? = nil
init(data: Data) {
self.data = data
@ -38,8 +39,7 @@ class Profile: ObservableObject {
self.uuid = UUID().uuidString
}
} catch {
appName = "Failed to decode this profile."
appId = error.localizedDescription
self.decodeError = error.localizedDescription
uuid = UUID().uuidString
}
}
@ -132,52 +132,106 @@ struct ProfileView: View {
@State private var removeTargetName: String = ""
@State private var removeTargetUUID: String = ""
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
infoCard
List {
if working && entries.isEmpty {
Section {
HStack {
Spacer()
ProgressView("Loading...")
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
if alert {
CustomErrorView(title: alertTitle,
message: alertMsg,
onDismiss: { alert = false },
messageType: alertSuccess ? .success : .error)
} else if entries.isEmpty && notMatchedProfiles.isEmpty {
Section {
Text("No profiles found.")
.foregroundStyle(.secondary)
}
}
if confirmRemove {
CustomErrorView(
title: "Confirm Removal",
message: "Remove profile for \(removeTargetName) (UUID: \(removeTargetUUID))?\n**Apps associated with this profile may become unavailable.**",
onDismiss: { confirmRemove = false },
showButton: true,
primaryButtonText: "Remove",
secondaryButtonText: "Cancel",
onPrimaryButtonTap: {
Task { await removeProfile(uuid: removeTargetUUID) }
},
onSecondaryButtonTap: {
// Just dismiss
},
showSecondaryButton: true,
messageType: .info
)
ForEach(entries) { entry in
Section {
// Header/Status Row
VStack(alignment: .leading, spacing: 4) {
if let match = entry.bestMatchingProfile {
HStack {
Image(systemName: "clock")
Text("Expires: \(match.profile.formattedDate)")
}
.foregroundStyle(match.profile.dateColor)
.font(.subheadline)
} else {
HStack {
Image(systemName: "exclamationmark.triangle")
Text("No matching profile")
}
.font(.subheadline)
.foregroundColor(.refreshRed)
}
Text(entry.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
// Profiles
if let recent = entry.mostRecentProfile {
profileRow(match: recent, isMostRecent: true)
}
if let best = entry.bestMatchingProfile, best.profile.uuid != entry.mostRecentProfile?.profile.uuid {
profileRow(match: best, isMostRecent: false)
}
if entry.profileMatches.count > 1 {
let showMore = expandedApps.contains(entry.id)
let extraProfiles = entry.profileMatches.dropFirst(recentAndBestCount(for: entry))
if !extraProfiles.isEmpty {
if showMore {
ForEach(extraProfiles, id: \.profile.uuid) { match in
profileRow(match: match, isMostRecent: false)
}
}
Button {
withAnimation {
if showMore { expandedApps.remove(entry.id) }
else { expandedApps.insert(entry.id) }
}
} label: {
Label(showMore ? "Hide older profiles" : "Show \(extraProfiles.count) older profiles",
systemImage: showMore ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.blue)
}
}
}
} header: {
Text(entry.name)
}
}
if !notMatchedProfiles.isEmpty {
Section("Other Profiles") {
ForEach(notMatchedProfiles) { entry in
VStack(alignment: .leading) {
Text(entry.id)
.font(.caption.monospaced())
.foregroundStyle(.primary)
}
ForEach(entry.profileMatches, id: \.profile.uuid) { match in
profileRow(match: match, isMostRecent: false)
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("App Expiry")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
@ -195,7 +249,6 @@ struct ProfileView: View {
}
}
.onAppear { Task { await loadData() } }
.fileImporter(
isPresented: $isImporterPresented,
@ -218,130 +271,18 @@ struct ProfileView: View {
}
}
}
.preferredColorScheme(preferredScheme)
}
// MARK: - UI Sections
private var infoCard: some View {
VStack {
HStack {
Text("Sideloaded Apps")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
LazyVStack(alignment: .leading, spacing: 14) {
if entries.isEmpty {
Text(working ? "Loading Apps" : "No sideloaded apps found")
} else {
ForEach(entries) { entry in
appRow(for: entry)
if entry.id != entries.last?.id {
Divider()
.background(Color.white.opacity(0.12))
.padding(.vertical, 4)
}
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
if !notMatchedProfiles.isEmpty {
HStack {
Text("Other Profiles")
.padding(.top, 10.0)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(notMatchedProfiles) { entry in
appRow(for: entry)
if entry.id != notMatchedProfiles.last?.id {
Divider()
.background(Color.white.opacity(0.12))
.padding(.vertical, 4)
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
}
}
private func appRow(for entry: AppProfileStatus) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(entry.name)
.font(.headline)
.foregroundStyle(.primary)
Spacer()
if let match = entry.bestMatchingProfile {
Text("Expires: \(match.profile.formattedDate)")
.foregroundStyle(match.profile.dateColor)
.font(.caption)
} else {
Text("No matching profile")
.font(.caption)
.foregroundColor(.refreshRed)
}
}
Text(entry.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
if let recent = entry.mostRecentProfile {
profileRow(match: recent, isMostRecent: true)
}
if let best = entry.bestMatchingProfile, best.profile.uuid != entry.mostRecentProfile?.profile.uuid {
profileRow(match: best, isMostRecent: false)
}
if entry.profileMatches.count > 1 {
if expandedApps.contains(entry.id) {
ForEach(entry.profileMatches.dropFirst(recentAndBestCount(for: entry)), id: \.profile.uuid) { match in
profileRow(match: match, isMostRecent: false)
}
}
Button {
withAnimation(.easeInOut(duration: 0.25)) {
if expandedApps.contains(entry.id) {
expandedApps.remove(entry.id)
} else {
expandedApps.insert(entry.id)
}
}
} label: {
Label(expandedApps.contains(entry.id) ? "Hide older profiles" : "Show older profiles",
systemImage: expandedApps.contains(entry.id) ? "chevron.up" : "chevron.down")
.font(.caption)
.labelStyle(.titleAndIcon)
.foregroundStyle(.secondary)
.alert(alertTitle, isPresented: $alert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMsg)
}
.alert("Confirm Removal", isPresented: $confirmRemove) {
Button("Remove", role: .destructive) {
Task { await removeProfile(uuid: removeTargetUUID) }
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Remove profile for \(removeTargetName) (UUID: \(removeTargetUUID))?\nApps associated with this profile may become unavailable.")
}
}
@ -387,8 +328,8 @@ struct ProfileView: View {
.textSelection(.enabled)
}
Spacer()
HStack {
profileActionButton(icon: "square.and.arrow.down", color: accentColor) {
HStack(spacing: 16) {
profileActionButton(icon: "square.and.arrow.down", color: .blue) {
saveProfile(profile: match.profile)
}
profileActionButton(icon: "trash", color: .refreshRed) {
@ -409,19 +350,7 @@ struct ProfileView: View {
}
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(match.missingEntitlements.isEmpty ? Color.white.opacity(0.1) : Color.refreshRed.opacity(0.6), lineWidth: 1)
)
)
.background(
match.missingEntitlements.isEmpty ? Color.clear : Color.refreshRed.opacity(0.08)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.vertical, 4)
}
// MARK: - Data loading
@ -443,7 +372,21 @@ struct ProfileView: View {
}
return (parsedProfiles, parsedApps)
}.value
let groupedProfiles = Dictionary(grouping: profiles) { profileIdentifier(from: $0) }
let failedProfiles = profiles.filter { $0.decodeError != nil }
let validProfiles = profiles.filter { $0.decodeError == nil }
if !failedProfiles.isEmpty {
let errors = failedProfiles.map { $0.decodeError ?? "Unknown error" }
let uniqueErrors = Array(Set(errors))
await MainActor.run {
alertTitle = "Failed to Decode \(failedProfiles.count) Profile\(failedProfiles.count == 1 ? "" : "s")"
alertMsg = uniqueErrors.joined(separator: "\n")
alert = true
}
}
let groupedProfiles = Dictionary(grouping: validProfiles) { profileIdentifier(from: $0) }
.mapValues { profiles in
profiles.sorted { lhs, rhs in
let leftDate = lhs.expirationDate ?? .distantPast

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
//
// SharedStubs.swift
// StikJIT
//
// Created by Stephen on 09/12/2025.
//
import SwiftUI
import UniformTypeIdentifiers
import UIKit
// MARK: - Shared glass card wrapper (renamed to avoid conflicts)
struct MaterialCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
}
@ViewBuilder
func appGlassCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
MaterialCard {
content()
}
}
// MARK: - Open App Folder helper
func openAppFolder() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let controller = UIActivityViewController(activityItems: [docs], applicationActivities: nil)
controller.excludedActivityTypes = [.assignToContact, .saveToCameraRoll, .postToFacebook, .postToTwitter]
DispatchQueue.main.async {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else { return }
root.present(controller, animated: true)
}
}

View file

@ -1,77 +0,0 @@
//
// TextAlert.swift
// StikJIT
//
// Created by s s on 2025/5/11.
//
import SwiftUI
import UIKit
public struct TextFieldAlertModifier: ViewModifier {
@State private var alertController: UIAlertController?
@Binding var isPresented: Bool
let title: String
let text: Binding<String>
let placeholder: String
let action: (String?) -> Void
let actionCancel: (String?) -> Void
public func body(content: Content) -> some View {
content.onChange(of: isPresented) { isPresented in
if isPresented, alertController == nil {
let alertController = makeAlertController()
self.alertController = alertController
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
return
}
scene.windows.first?.rootViewController?.present(alertController, animated: true)
} else if !isPresented, let alertController = alertController {
alertController.dismiss(animated: true)
self.alertController = nil
}
}
}
private func makeAlertController() -> UIAlertController {
let controller = UIAlertController(title: title, message: nil, preferredStyle: .alert)
controller.addTextField {
$0.placeholder = self.placeholder
$0.text = self.text.wrappedValue
$0.clearButtonMode = .always
}
controller.addAction(UIAlertAction(title: "Cancel".localized, style: .cancel) { _ in
self.actionCancel(nil)
shutdown()
})
controller.addAction(UIAlertAction(title: "OK".localized, style: .default) { _ in
self.action(controller.textFields?.first?.text)
shutdown()
})
return controller
}
private func shutdown() {
isPresented = false
alertController = nil
}
}
extension View {
public func textFieldAlert(
isPresented: Binding<Bool>,
title: String,
text: Binding<String>,
placeholder: String = "",
action: @escaping (String?) -> Void,
actionCancel: @escaping (String?) -> Void
) -> some View {
self.modifier(TextFieldAlertModifier(isPresented: isPresented, title: title, text: text, placeholder: placeholder, action: action, actionCancel: actionCancel))
}
}

View file

@ -0,0 +1,50 @@
//
// ToolsView.swift
// StikJIT
//
// Created by Stephen on 2/23/26.
//
import SwiftUI
struct ToolsView: View {
private struct ToolItem: Identifiable {
let id: String
let title: String
let detail: String
let systemImage: String
let destination: AnyView
}
private var tools: [ToolItem] {
[
ToolItem(id: "console", title: "Console", detail: "Live device logs", systemImage: "terminal", destination: AnyView(ConsoleLogsView())),
ToolItem(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", systemImage: "iphone.and.arrow.forward", destination: AnyView(DeviceInfoView())),
ToolItem(id: "profiles", title: "App Expiry", detail: "Check app expiration dates", systemImage: "calendar.badge.clock", destination: AnyView(ProfileView())),
ToolItem(id: "processes", title: "Processes", detail: "Inspect running apps", systemImage: "rectangle.stack.person.crop", destination: AnyView(ProcessInspectorView())),
ToolItem(id: "location", title: "Location Simulation", detail: "Simulate GPS location", systemImage: "location", destination: AnyView(LocationSimulationView()))
]
}
var body: some View {
NavigationStack {
List(tools) { tool in
NavigationLink {
tool.destination
} label: {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(tool.title)
Text(tool.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: tool.systemImage)
}
}
}
.navigationTitle("Tools")
}
}
}

View file

@ -1,33 +0,0 @@
"Welcome to StikDebug %@!" = "Welcome to StikDebug %@!";
"Click connect to get started" = "Click connect to get started";
"Pick pairing file to get started" = "Pick pairing file to get started";
"Device Not Mounted" = "Device Not Mounted";
"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "The Developer Disk Image has not been mounted yet. Connect to Wi-Fi and force-restart StikDebug.";
"Connect by App" = "Connect by App";
"Select Pairing File" = "Select Pairing File";
"Connect by PID" = "Connect by PID";
"Open Console" = "Open Console";
"Processing pairing file..." = "Processing pairing file...";
"\u2713 Pairing file successfully imported" = "\u2713 Pairing file successfully imported";
"Please enter the PID of the process you want to connect to" = "Please enter the PID of the process you want to connect to";
"Invalid PID" = "Invalid PID";
"Success" = "Success";
"JIT has been enabled for pid %d." = "JIT has been enabled for pid %d.";
"Installed Apps" = "Installed Apps";
"No Debuggable App Found" = "No Debuggable App Found";
"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate.";
"Favorites (%d/4)" = "Favorites (%d/4)";
"Recents" = "Recents";
"All Applications" = "All Applications";
"Remove Favorite" = "Remove Favorite";
"Add to Favorites" = "Add to Favorites";
"Copy Bundle ID" = "Copy Bundle ID";
"Delete" = "Delete";
"Loading..." = "Loading...";
"Error Occurred While Executing the Default Script." = "Error Occurred While Executing the Default Script.";
"Unsupported OS Version" = "Unsupported OS Version";
"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@";
"Cancel" = "Cancel";
"OK" = "OK";
"Enable Advanced Options" = "Enable Advanced Options";

View file

@ -1,33 +0,0 @@
"Welcome to StikDebug %@!" = "\u00a1Bienvenido a StikDebug %@!";
"Click connect to get started" = "Haz clic en conectar para comenzar";
"Pick pairing file to get started" = "Selecciona el archivo de emparejamiento para comenzar";
"Device Not Mounted" = "Dispositivo no montado";
"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "La imagen del disco del desarrollador a\u00fan no se ha montado. Con\u00e9ctese a la red Wi‑Fi y fuerce el reinicio de StikDebug.";
"Connect by App" = "Conectar por app";
"Select Pairing File" = "Seleccionar archivo de emparejamiento";
"Connect by PID" = "Conectar por PID";
"Open Console" = "Abrir consola";
"Processing pairing file..." = "Procesando archivo de emparejamiento...";
"\u2713 Pairing file successfully imported" = "\u2713 Archivo de emparejamiento importado correctamente";
"Please enter the PID of the process you want to connect to" = "Introduce el PID del proceso al que deseas conectarte";
"Invalid PID" = "PID no v\u00e1lido";
"Success" = "\u00c9xito";
"JIT has been enabled for pid %d." = "JIT habilitado para el pid %d.";
"Installed Apps" = "Aplicaciones instaladas";
"No Debuggable App Found" = "No se encontr\u00f3 ninguna aplicaci\u00f3n depurable";
"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug solo puede conectarse a aplicaciones con el derecho \"get-task-allow\". Verifica que la aplicaci\u00f3n a la que deseas conectarte est\u00e9 firmada con un certificado de desarrollo.";
"Favorites (%d/4)" = "Favoritos (%d/4)";
"Recents" = "Recientes";
"All Applications" = "Todas las aplicaciones";
"Remove Favorite" = "Eliminar favorito";
"Add to Favorites" = "A\u00f1adir a favoritos";
"Copy Bundle ID" = "Copiar Bundle ID";
"Delete" = "Eliminar";
"Loading..." = "Cargando...";
"Error Occurred While Executing the Default Script." = "Ocurri\u00f3 un error al ejecutar el script predeterminado.";
"Unsupported OS Version" = "Versi\u00f3n de iOS no compatible";
"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT solo es compatible con la versi\u00f3n 17.4 o superior. Tu dispositivo ejecuta iOS/iPadOS %@";
"Cancel" = "Cancelar";
"OK" = "Aceptar";
"Enable Advanced Options" = "Habilitar opciones avanzadas";

View file

@ -70,7 +70,6 @@ static JITEnableContext* sharedJITContext = nil;
va_start(args, format);
NSString* fmt = [NSString stringWithCString:format encoding:NSASCIIStringEncoding];
NSString* message = [[NSString alloc] initWithFormat:fmt arguments:args];
NSLog(@"%@", message);
if ([message containsString:@"ERROR"] || [message containsString:@"Error"]) {
[[LogManagerBridge shared] addErrorLog:message];
@ -92,16 +91,9 @@ static JITEnableContext* sharedJITContext = nil;
- (IdevicePairingFile*)getPairingFileWithError:(NSError**)error {
NSFileManager* fm = [NSFileManager defaultManager];
NSURL* docPathUrl = [fm URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
NSString* currentDeviceUUIDStr = [NSUserDefaults.standardUserDefaults stringForKey:@"DeviceLibraryActiveDeviceID"];
NSURL* pairingFileURL;
if(!currentDeviceUUIDStr || [currentDeviceUUIDStr isEqualToString:@"00000000-0000-0000-0000-000000000001"]) {
pairingFileURL = [docPathUrl URLByAppendingPathComponent:@"pairingFile.plist"];
} else {
pairingFileURL = [docPathUrl URLByAppendingPathComponent:[NSString stringWithFormat:@"DeviceLibrary/Pairings/%@.mobiledevicepairing", currentDeviceUUIDStr]];
}
NSURL* pairingFileURL = [docPathUrl URLByAppendingPathComponent:@"pairingFile.plist"];
if (![fm fileExistsAtPath:pairingFileURL.path]) {
NSLog(@"Pairing file not found!");
*error = [self errorWithStr:@"Pairing file not found!" code:-17];
return nil;
}
@ -182,8 +174,6 @@ static JITEnableContext* sharedJITContext = nil;
intptr_t isTimeout = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (uint64_t)(5 * NSEC_PER_SEC)));
if(isTimeout) {
Ccompletion(-1, "Heartbeat failed to complete in reasonable time.");
} else {
NSLog(@"Start heartbeat success %p", pthread_self());
}
os_unfair_lock_lock(&heartbeatLock);
@ -203,34 +193,6 @@ static JITEnableContext* sharedJITContext = nil;
return YES;
}
- (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback {
NSError* err = nil;
[self ensureHeartbeatWithError:&err];
if(err) {
logger(err.localizedDescription);
return NO;
}
return debug_app(provider,
[bundleID UTF8String],
[self createCLogger:logger], jsCallback) == 0;
}
- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback {
NSError* err = nil;
[self ensureHeartbeatWithError:&err];
if(err) {
logger(err.localizedDescription);
return NO;
}
return debug_app_pid(provider,
pid,
[self createCLogger:logger], jsCallback) == 0;
}
- (void)dealloc {

View file

@ -256,16 +256,20 @@ NSDictionary<NSString*, NSString*>* list_hidden_system_apps(IdeviceProviderHandl
UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error) {
SpringBoardServicesClientHandle *client = NULL;
if (springboard_services_connect(provider, &client)) {
*error = @"Failed to connect to SpringBoard Services";
IdeviceFfiError *err = springboard_services_connect(provider, &client);
if (err) {
*error = [NSString stringWithUTF8String:err->message ?: "Failed to connect to SpringBoard Services"];
idevice_error_free(err);
return nil;
}
void *pngData = NULL;
size_t dataLen = 0;
if (springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen)) {
err = springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen);
if (err) {
*error = [NSString stringWithUTF8String:err->message ?: "Failed to get app icon"];
idevice_error_free(err);
springboard_services_free(client);
*error = @"Failed to get app icon";
return nil;
}

View file

@ -20,55 +20,42 @@ void startHeartbeat(IdevicePairingFile* pairing_file, IdeviceProviderHandle** pr
IdeviceProviderHandle* newProvider = *provider;
IdeviceFfiError* err = nil;
// Create the socket address (replace with your device's IP)
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(LOCKDOWN_PORT);
NSString* deviceIP = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"];
inet_pton(AF_INET, deviceIP ? [deviceIP UTF8String] : "10.7.0.1", &addr.sin_addr);
NSString* deviceIP = [[NSUserDefaults standardUserDefaults] stringForKey:@"customTargetIP"];
inet_pton(AF_INET, (deviceIP && deviceIP.length > 0) ? [deviceIP UTF8String] : "10.7.0.1", &addr.sin_addr);
err = idevice_tcp_provider_new((struct sockaddr *)&addr, pairing_file,
"ExampleProvider", &newProvider);
if (err != NULL) {
fprintf(stderr, "Failed to create TCP provider: [%d] %s", err->code,
err->message);
completion(err->code, err->message);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return;
}
// Connect to installation proxy
HeartbeatClientHandle *client = NULL;
err = heartbeat_connect(newProvider, &client);
if (err != NULL) {
fprintf(stderr, "Failed to connect to installation proxy: [%d] %s",
err->code, err->message);
completion(err->code, err->message);
idevice_provider_free(newProvider);
idevice_error_free(err);
return;
}
*provider = newProvider;
bool completionCalled = false;
u_int64_t current_interval = 15;
while (1) {
// Get the new interval
u_int64_t new_interval = 0;
err = heartbeat_get_marco(client, current_interval, &new_interval);
if (err != NULL) {
fprintf(stderr, "Failed to get marco: [%d] %s token = %d, pthread_self = %p\n", err->code, err->message, heartbeatToken, pthread_self());
if(!completionCalled) {
if (!completionCalled) {
completion(err->code, err->message);
}
heartbeat_client_free(client);
@ -76,40 +63,33 @@ void startHeartbeat(IdevicePairingFile* pairing_file, IdeviceProviderHandle** pr
return;
}
// if a new heartbeat thread is running we quit current one
// If a newer heartbeat thread has started, yield to it
if (heartbeatToken != globalHeartbeatToken) {
heartbeat_client_free(client);
NSLog(@"Quitting %d, now token = %d", heartbeatToken, globalHeartbeatToken);
return;
}
current_interval = new_interval + 5;
// Reply
err = heartbeat_send_polo(client);
if (err != NULL) {
fprintf(stderr, "Failed to get marco: [%d] %s", err->code, err->message);
if(!completionCalled) {
if (!completionCalled) {
completion(err->code, err->message);
}
heartbeat_client_free(client);
idevice_error_free(err);
return;
}
if (lastHeartbeatDate && [[NSDate now] timeIntervalSinceDate:lastHeartbeatDate] > current_interval) {
lastHeartbeatDate = nil;
// NSLog(@"[SJ] Heartbeat marco receive timeout, probably disconnected, token = %d, pthread_self = %p", heartbeatToken, pthread_self());
return;
}
lastHeartbeatDate = [NSDate now];
// NSLog(@"[SJ] Heartbeat finished at %@, token = %d, pthread_self = %p", lastHeartbeatDate, heartbeatToken, pthread_self());
if (!completionCalled) {
completion(0, "Heartbeat succeeded");
completionCalled = true;
}
}
}

View file

@ -15,13 +15,13 @@
NSError* makeError(int code, NSString* msg);
LockdowndClientHandle* ideviceinfo_c_init(IdeviceProviderHandle* g_provider, IdevicePairingFile* g_sess_pf, NSError** error) {
struct LockdowndClientHandle * g_client = NULL;
struct IdeviceFfiError * err = lockdownd_connect(g_provider, &g_client);
LockdowndClientHandle *g_client = NULL;
IdeviceFfiError *err = lockdownd_connect(g_provider, &g_client);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_pairing_file_free(g_sess_pf);
idevice_error_free(err);
return 0;
return NULL;
}
err = lockdownd_start_session(g_client, g_sess_pf);
@ -30,8 +30,7 @@ LockdowndClientHandle* ideviceinfo_c_init(IdeviceProviderHandle* g_provider, Ide
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
lockdownd_client_free(g_client);
g_client = NULL;
return 0;
return NULL;
}
return g_client;
@ -64,22 +63,15 @@ char *ideviceinfo_c_get_xml(LockdowndClientHandle* g_client, NSError** error) {
- (LockdowndClientHandle*)ideviceInfoInit:(NSError**)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return 0;
}
IdevicePairingFile* pf = [self getPairingFileWithError:error];
if(*error) {
return 0;
}
if (*error) { return nil; }
IdevicePairingFile *pf = [self getPairingFileWithError:error];
if (*error) { return nil; }
return ideviceinfo_c_init(provider, pf, error);
}
- (char*)ideviceInfoGetXMLWithLockdownClient:(LockdowndClientHandle*)lockdownClient error:(NSError**)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return 0;
}
if (*error) { return NULL; }
return ideviceinfo_c_get_xml(lockdownClient, error);
}
@end

View file

@ -20,12 +20,65 @@
#import "JITEnableContext.h"
#import "JITEnableContextInternal.h"
// MARK: - Shared debug session
typedef struct {
AdapterHandle *adapter;
RsdHandshakeHandle *handshake;
RemoteServerHandle *remote_server;
DebugProxyHandle *debug_proxy;
} DebugSession;
static void debug_session_free(DebugSession *s) {
if (s->debug_proxy) { debug_proxy_free(s->debug_proxy); s->debug_proxy = NULL; }
if (s->remote_server) { remote_server_free(s->remote_server); s->remote_server = NULL; }
if (s->handshake) { rsd_handshake_free(s->handshake); s->handshake = NULL; }
if (s->adapter) { adapter_free(s->adapter); s->adapter = NULL; }
}
// Connects to the device, performs the RSD handshake, and sets up the debug proxy.
// Returns 0 on success; cleans up any partial state and returns 1 on failure.
static int connect_debug_session(IdeviceProviderHandle *tcp_provider, DebugSession *out) {
memset(out, 0, sizeof(*out));
IdeviceFfiError *err = NULL;
CoreDeviceProxyHandle *core_device = NULL;
err = core_device_proxy_connect(tcp_provider, &core_device);
if (err) { idevice_error_free(err); return 1; }
uint16_t rsd_port = 0;
err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port);
if (err) { idevice_error_free(err); core_device_proxy_free(core_device); return 1; }
err = core_device_proxy_create_tcp_adapter(core_device, &out->adapter);
if (err) { idevice_error_free(err); core_device_proxy_free(core_device); return 1; }
core_device = NULL; // ownership transferred to adapter
AdapterStreamHandle *stream = NULL;
err = adapter_connect(out->adapter, rsd_port, (ReadWriteOpaque **)&stream);
if (err) { idevice_error_free(err); debug_session_free(out); return 1; }
err = rsd_handshake_new((ReadWriteOpaque *)stream, &out->handshake);
if (err) { idevice_error_free(err); adapter_close(stream); debug_session_free(out); return 1; }
stream = NULL; // consumed by handshake
err = remote_server_connect_rsd(out->adapter, out->handshake, &out->remote_server);
if (err) { idevice_error_free(err); debug_session_free(out); return 1; }
err = debug_proxy_connect_rsd(out->adapter, out->handshake, &out->debug_proxy);
if (err) { idevice_error_free(err); debug_session_free(out); return 1; }
return 0;
}
// MARK: - Debug server commands
void runDebugServerCommand(int pid,
DebugProxyHandle* debug_proxy,
RemoteServerHandle* remote_server,
LogFuncC logger,
DebugAppCallback callback) {
// enable QStartNoAckMode
// Enable QStartNoAckMode
char *disableResponse = NULL;
debug_proxy_send_ack(debug_proxy);
debug_proxy_send_ack(debug_proxy);
@ -36,14 +89,13 @@ void runDebugServerCommand(int pid,
idevice_string_free(disableResponse);
debug_proxy_set_ack_mode(debug_proxy, false);
if(callback) {
if (callback) {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
callback(pid, debug_proxy, remote_server, semaphore);
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
err = debug_proxy_send_raw(debug_proxy, "\x03", 1);
usleep(500);
} else {
// Send vAttach command with PID in hex
char attach_command[64];
snprintf(attach_command, sizeof(attach_command), "vAttach;%" PRIx64, pid);
@ -56,14 +108,13 @@ void runDebugServerCommand(int pid,
char *attach_response = NULL;
err = debug_proxy_send_command(debug_proxy, attach_cmd, &attach_response);
debugserver_command_free(attach_cmd);
if (err) {
logger("Failed to attach to process: %d", err);
logger("Failed to attach to process: %d", err->code);
idevice_error_free(err);
} else if (attach_response != NULL) {
logger("Attach response: %s", attach_response);
idevice_string_free(attach_response);
}
}
// Send detach command
@ -74,7 +125,6 @@ void runDebugServerCommand(int pid,
char *detach_response = NULL;
err = debug_proxy_send_command(debug_proxy, detach_cmd, &detach_response);
debugserver_command_free(detach_cmd);
if (err) {
logger("Failed to detach from process: %d", err->code);
idevice_error_free(err);
@ -85,238 +135,49 @@ void runDebugServerCommand(int pid,
}
}
// MARK: - Public entry points
int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger, DebugAppCallback callback) {
// Initialize logger
// idevice_init_logger(Info, Disabled, NULL);
IdeviceFfiError* err = 0;
DebugSession session;
if (connect_debug_session(tcp_provider, &session) != 0) return 1;
CoreDeviceProxyHandle *core_device = NULL;
err = core_device_proxy_connect(tcp_provider, &core_device);
if (err != NULL) {
fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
return 1;
}
uint16_t rsd_port;
err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port);
if (err != NULL) {
fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
core_device_proxy_free(core_device);
return 1;
}
printf("Server RSD Port: %d\n", rsd_port);
printf("\n=== Creating TCP Tunnel Adapter ===\n");
AdapterHandle *adapter = NULL;
err = core_device_proxy_create_tcp_adapter(core_device, &adapter);
if (err != NULL) {
fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
core_device_proxy_free(core_device);
return 1;
}
core_device = NULL; // adapter takes ownership of the proxy
AdapterStreamHandle *stream = NULL;
err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream);
if (err != NULL) {
fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
adapter_free(adapter);
return 1;
}
printf("Successfully connected to RSD port\n");
printf("\n=== Performing RSD Handshake ===\n");
RsdHandshakeHandle *handshake = NULL;
err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake);
if (err != NULL) {
fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
adapter_close(stream);
adapter_free(adapter);
return 1;
}
stream = NULL;
// Create RemoteServerClient
RemoteServerHandle *remote_server = NULL;
err = remote_server_connect_rsd(adapter, handshake, &remote_server);
if (err != NULL) {
fprintf(stderr, "Failed to create remote server: [%d] %s", err->code,
err->message);
idevice_error_free(err);
adapter_free(adapter);
rsd_handshake_free(handshake);
return 1;
}
printf("\n=== Testing Process Control ===\n");
// Create ProcessControlClient
ProcessControlHandle *process_control = NULL;
err = process_control_new(remote_server, &process_control);
if (err != NULL) {
fprintf(stderr, "Failed to create process control client: [%d] %s",
err->code, err->message);
IdeviceFfiError *err = process_control_new(session.remote_server, &process_control);
if (err) {
idevice_error_free(err);
remote_server_free(remote_server);
debug_session_free(&session);
return 1;
}
// Launch application
uint64_t pid;
err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0,
true, false, &pid);
if (err != NULL) {
fprintf(stderr, "Failed to launch app: [%d] %s", err->code, err->message);
uint64_t pid = 0;
err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, true, false, &pid);
if (err) {
idevice_error_free(err);
process_control_free(process_control);
remote_server_free(remote_server);
return 1;
}
printf("Successfully launched app with PID: %llu\n", pid);
printf("\n=== Setting up Debug Proxy ===\n");
DebugProxyHandle *debug_proxy = NULL;
err = debug_proxy_connect_rsd(adapter, handshake, &debug_proxy);
if (err != NULL) {
fprintf(stderr, "Failed to create debug proxy client: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
rsd_handshake_free(handshake);
adapter_free(adapter);
debug_session_free(&session);
return 1;
}
runDebugServerCommand((int)pid, debug_proxy, remote_server, logger, callback);
runDebugServerCommand((int)pid, session.debug_proxy, session.remote_server, logger, callback);
/*****************************************************************
* Cleanup
*****************************************************************/
debug_proxy_free(debug_proxy);
process_control_free(process_control);
remote_server_free(remote_server);
rsd_handshake_free(handshake);
adapter_free(adapter);
debug_session_free(&session);
logger("Debug session completed");
return 0;
}
int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, DebugAppCallback callback) {
IdeviceFfiError* err = 0;
DebugSession session;
if (connect_debug_session(tcp_provider, &session) != 0) return 1;
CoreDeviceProxyHandle *core_device = NULL;
err = core_device_proxy_connect(tcp_provider, &core_device);
if (err != NULL) {
fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
return 1;
}
uint16_t rsd_port;
err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port);
if (err != NULL) {
fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
core_device_proxy_free(core_device);
return 1;
}
printf("Server RSD Port: %d\n", rsd_port);
printf("\n=== Creating TCP Tunnel Adapter ===\n");
AdapterHandle *adapter = NULL;
err = core_device_proxy_create_tcp_adapter(core_device, &adapter);
if (err != NULL) {
fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
core_device_proxy_free(core_device);
return 1;
}
core_device = NULL;
AdapterStreamHandle *stream = NULL;
err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream);
if (err != NULL) {
fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
adapter_free(adapter);
return 1;
}
printf("Successfully connected to RSD port\n");
printf("\n=== Performing RSD Handshake ===\n");
RsdHandshakeHandle *handshake = NULL;
err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake);
if (err != NULL) {
fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
adapter_close(stream);
adapter_free(adapter);
return 1;
}
stream = NULL;
// Create RemoteServerClient
RemoteServerHandle *remote_server = NULL;
err = remote_server_connect_rsd(adapter, handshake, &remote_server);
if (err != NULL) {
fprintf(stderr, "Failed to create remote server: [%d] %s", err->code,
err->message);
idevice_error_free(err);
adapter_free(adapter);
rsd_handshake_free(handshake);
return 1;
}
printf("\n=== Setting up Debug Proxy ===\n");
DebugProxyHandle *debug_proxy = NULL;
err = debug_proxy_connect_rsd(adapter, handshake, &debug_proxy);
if (err != NULL) {
fprintf(stderr, "Failed to create debug proxy client: [%d] %s\n", err->code,
err->message);
idevice_error_free(err);
rsd_handshake_free(handshake);
adapter_free(adapter);
return 1;
}
runDebugServerCommand(pid, debug_proxy, remote_server, logger, callback);
/*****************************************************************
* Cleanup
*****************************************************************/
debug_proxy_free(debug_proxy);
rsd_handshake_free(handshake);
adapter_free(adapter);
runDebugServerCommand(pid, session.debug_proxy, session.remote_server, logger, callback);
debug_session_free(&session);
logger("Debug session completed");
return 0;
}
int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger) {
// idevice_init_logger(Info, Disabled, NULL);
IdeviceFfiError* err = NULL;
CoreDeviceProxyHandle *core_device = NULL;
@ -329,101 +190,46 @@ int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle
int result = 1;
err = core_device_proxy_connect(tcp_provider, &core_device);
if (err != NULL) {
fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
uint16_t rsd_port = 0;
err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port);
if (err != NULL) {
fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
err = core_device_proxy_create_tcp_adapter(core_device, &adapter);
if (err != NULL) {
fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
core_device = NULL; // ownership transferred to adapter
err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream);
if (err != NULL) {
fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake);
if (err != NULL) {
fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
stream = NULL; // consumed by handshake/adapter stack
err = remote_server_connect_rsd(adapter, handshake, &remote_server);
if (err != NULL) {
fprintf(stderr, "Failed to create remote server: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
goto cleanup;
}
if (err) { idevice_error_free(err); goto cleanup; }
err = process_control_new(remote_server, &process_control);
if (err != NULL) {
fprintf(stderr, "Failed to create process control client: [%d] %s\n", err->code, err->message);
if (err) { idevice_error_free(err); goto cleanup; }
err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, false, true, &pid);
if (err) {
idevice_error_free(err);
if (logger) logger("Failed to launch app: %s", bundle_id);
goto cleanup;
}
err = process_control_launch_app(process_control,
bundle_id,
NULL,
0,
NULL,
0,
false,
true,
&pid);
if (err != NULL) {
fprintf(stderr, "Failed to launch app: [%d] %s\n", err->code, err->message);
idevice_error_free(err);
if (logger) {
logger("Failed to launch app: %s", bundle_id);
}
goto cleanup;
}
if (logger) {
logger("Launched app (PID %llu)", pid);
}
if (logger) logger("Launched app (PID %llu)", pid);
result = 0;
cleanup:
if (process_control) {
process_control_free(process_control);
}
if (remote_server) {
remote_server_free(remote_server);
}
if (handshake) {
rsd_handshake_free(handshake);
}
if (stream) {
adapter_close(stream);
}
if (adapter) {
adapter_free(adapter);
}
if (core_device) {
core_device_proxy_free(core_device);
}
if (process_control) process_control_free(process_control);
if (remote_server) remote_server_free(remote_server);
if (handshake) rsd_handshake_free(handshake);
if (stream) adapter_close(stream);
if (adapter) adapter_free(adapter);
if (core_device) core_device_proxy_free(core_device);
return result;
}
@ -433,42 +239,31 @@ cleanup:
- (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback {
NSError* err = nil;
[self ensureHeartbeatWithError:&err];
if(err) {
if (err) {
logger(err.localizedDescription);
return NO;
}
return debug_app(provider,
[bundleID UTF8String],
[self createCLogger:logger], jsCallback) == 0;
return debug_app(provider, [bundleID UTF8String], [self createCLogger:logger], jsCallback) == 0;
}
- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback {
NSError* err = nil;
[self ensureHeartbeatWithError:&err];
if(err) {
if (err) {
logger(err.localizedDescription);
return NO;
}
return debug_app_pid(provider,
pid,
[self createCLogger:logger], jsCallback) == 0;
return debug_app_pid(provider, pid, [self createCLogger:logger], jsCallback) == 0;
}
- (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger {
NSError* err = nil;
[self ensureHeartbeatWithError:&err];
if(err) {
if (err) {
logger(err.localizedDescription);
return NO;
}
int result = launch_app_via_proxy(provider,
[bundleID UTF8String],
[self createCLogger:logger]);
return result == 0;
return launch_app_via_proxy(provider, [bundleID UTF8String], [self createCLogger:logger]) == 0;
}
@end

View file

@ -1,11 +1,11 @@
//
// ls.c
// location_simulation.c
// StikDebug
//
// Created by Stephen on 8/3/25.
//
#include "ls.h"
#include "location_simulation.h"
#include "idevice.h"
#include <arpa/inet.h>
#include <netinet/in.h>
@ -36,7 +36,6 @@ int simulate_location(const char *device_ip,
double longitude,
const char *pairing_file)
{
idevice_init_logger(Debug, Disabled, NULL);
IdeviceFfiError *err = NULL;
if (g_location_sim) {
@ -98,17 +97,21 @@ int simulate_location(const char *device_ip,
cleanup_on_error();
return IPA_ERR_ADAPTER_CREATE;
}
// core_device_proxy_create_tcp_adapter takes ownership of g_core_device
// (Rust moves it into the adapter). Null the pointer so cleanup_on_error
// does not attempt a second free.
g_core_device = NULL;
AdapterStreamHandle *stream = NULL;
if ((err = adapter_connect(g_adapter, rsd_port, &stream))) {
if ((err = adapter_connect(g_adapter, rsd_port, (ReadWriteOpaque **)&stream))) {
idevice_error_free(err);
cleanup_on_error();
return IPA_ERR_STREAM;
}
if ((err = rsd_handshake_new(stream, &g_handshake))) {
if ((err = rsd_handshake_new((ReadWriteOpaque *)stream, &g_handshake))) {
idevice_error_free(err);
adapter_close(stream);
adapter_stream_close(stream);
cleanup_on_error();
return IPA_ERR_HANDSHAKE;
}
@ -121,6 +124,9 @@ int simulate_location(const char *device_ip,
cleanup_on_error();
return IPA_ERR_REMOTE_SERVER;
}
// remote_server_connect_rsd takes ownership of g_adapter and g_handshake.
g_adapter = NULL;
g_handshake = NULL;
if ((err = location_simulation_new(g_remote_server,
&g_location_sim))) {
@ -128,6 +134,8 @@ int simulate_location(const char *device_ip,
cleanup_on_error();
return IPA_ERR_LOCATION_SIM;
}
// location_simulation_new takes ownership of g_remote_server.
g_remote_server = NULL;
if ((err = location_simulation_set(g_location_sim,
latitude,

View file

@ -1,12 +1,12 @@
//
// ls.h
// location_simulation.h
// StikDebug
//
// Created by Stephen on 8/3/25.
//
#ifndef LS_H
#define LS_H
#ifndef LOCATION_SIMULATION_H
#define LOCATION_SIMULATION_H
#include <stdint.h>
@ -52,4 +52,4 @@ int clear_simulated_location(void);
}
#endif
#endif /* LS_H */
#endif /* LOCATION_SIMULATION_H */

View file

@ -10,44 +10,45 @@
@import Foundation;
NSError* makeError(int code, NSString* msg);
size_t getMountedDeviceCount(IdeviceProviderHandle* provider, NSError** error) {
ImageMounterHandle* client = 0;
IdeviceFfiError* err = image_mounter_connect(provider, &client);
ImageMounterHandle *client = NULL;
IdeviceFfiError *err = image_mounter_connect(provider, &client);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return 0;
}
plist_t* devices;
plist_t *devices = NULL;
size_t deviceLength = 0;
err = image_mounter_copy_devices(client, &devices, &deviceLength);
image_mounter_free(client);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return 0;
}
// no need to read the device, we just check the length
for(int i = 0;i < deviceLength; ++i) {
for (int i = 0; i < (int)deviceLength; i++) {
plist_free(devices[i]);
}
idevice_data_free((uint8_t *)devices, deviceLength*sizeof(plist_t));
image_mounter_free(client);
idevice_data_free((uint8_t *)devices, deviceLength * sizeof(plist_t));
return deviceLength;
}
int mountPersonalDDI(IdeviceProviderHandle* provider, IdevicePairingFile* pairingFile2, NSString* imagePath, NSString* trustcachePath, NSString* manifestPath, NSError** error) {
NSData* image = [NSData dataWithContentsOfFile:imagePath];
NSData* trustcache = [NSData dataWithContentsOfFile:trustcachePath];
NSData* buildManifest = [NSData dataWithContentsOfFile:manifestPath];
if(!image || !trustcache || !buildManifest) {
NSData *image = [NSData dataWithContentsOfFile:imagePath];
NSData *trustcache = [NSData dataWithContentsOfFile:trustcachePath];
NSData *buildManifest = [NSData dataWithContentsOfFile:manifestPath];
if (!image || !trustcache || !buildManifest) {
idevice_pairing_file_free(pairingFile2);
*error = makeError(1, @"Failed to read one or more files");
return 1;
}
LockdowndClientHandle* lockdownClient = 0;
IdeviceFfiError* err = lockdownd_connect(provider, &lockdownClient);
LockdowndClientHandle *lockdownClient = NULL;
IdeviceFfiError *err = lockdownd_connect(provider, &lockdownClient);
if (err) {
*error = makeError(6, @(err->message));
idevice_pairing_file_free(pairingFile2);
@ -60,29 +61,32 @@ int mountPersonalDDI(IdeviceProviderHandle* provider, IdevicePairingFile* pairin
if (err) {
*error = makeError(7, @(err->message));
idevice_error_free(err);
return 7; // EC: 7
lockdownd_client_free(lockdownClient);
return 7;
}
plist_t uniqueChipIDPlist = 0;
err = lockdownd_get_value(lockdownClient, "UniqueChipID", 0, &uniqueChipIDPlist);
plist_t uniqueChipIDPlist = NULL;
err = lockdownd_get_value(lockdownClient, "UniqueChipID", NULL, &uniqueChipIDPlist);
lockdownd_client_free(lockdownClient);
if (err) {
*error = makeError(8, @(err->message));
idevice_error_free(err);
return 8; // EC: 8
return 8;
}
uint64_t uniqueChipID = 0;
plist_get_uint_val(uniqueChipIDPlist, &uniqueChipID);
plist_free(uniqueChipIDPlist);
ImageMounterHandle* mounterClient = 0;
ImageMounterHandle *mounterClient = NULL;
err = image_mounter_connect(provider, &mounterClient);
if (err) {
*error = makeError(9, @(err->message));
idevice_error_free(err);
return 9; // EC: 9
return 9;
}
image_mounter_mount_personalized(
err = image_mounter_mount_personalized(
mounterClient,
provider,
[image bytes],
@ -91,36 +95,34 @@ int mountPersonalDDI(IdeviceProviderHandle* provider, IdevicePairingFile* pairin
[trustcache length],
[buildManifest bytes],
[buildManifest length],
nil,
NULL,
uniqueChipID
);
image_mounter_free(mounterClient);
if (err) {
*error = makeError(10, @(err->message));
idevice_error_free(err);
return 10; // EC: 10
return 10;
}
return 0;
}
@implementation JITEnableContext(DDI)
- (NSUInteger)getMountedDeviceCount:(NSError**)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return NO;
}
if (*error) { return 0; }
return getMountedDeviceCount(provider, error);
}
- (NSInteger)mountPersonalDDIWithImagePath:(NSString*)imagePath trustcachePath:(NSString*)trustcachePath manifestPath:(NSString*)manifestPath error:(NSError**)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return 0;
}
IdevicePairingFile* pairing = [self getPairingFileWithError:error];
if(*error) {
return 0;
}
if (*error) { return 0; }
IdevicePairingFile *pairing = [self getPairingFileWithError:error];
if (*error) { return 0; }
return mountPersonalDDI(provider, pairing, imagePath, trustcachePath, manifestPath, error);
}
@end

View file

@ -9,107 +9,115 @@
#import "JITEnableContextInternal.h"
@import Foundation;
@implementation JITEnableContext(Process)
// MARK: - Shared AppService session
- (NSArray<NSDictionary*>*)fetchProcessesViaAppServiceWithError:(NSError **)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return nil;
}
typedef struct {
AdapterHandle *adapter;
RsdHandshakeHandle *handshake;
AppServiceHandle *appService;
} AppServiceSession;
IdeviceProviderHandle *providerToUse = provider;
CoreDeviceProxyHandle *coreProxy = NULL;
AdapterHandle *adapter = NULL;
AdapterStreamHandle *stream = NULL;
RsdHandshakeHandle *handshake = NULL;
AppServiceHandle *appService = NULL;
ProcessTokenC *processes = NULL;
uintptr_t count = 0;
NSMutableArray *result = nil;
static void app_service_session_free(AppServiceSession *s) {
if (s->appService) { app_service_free(s->appService); s->appService = NULL; }
if (s->handshake) { rsd_handshake_free(s->handshake); s->handshake = NULL; }
if (s->adapter) { adapter_free(s->adapter); s->adapter = NULL; }
}
// Connects to the device via CoreDeviceProxy → Adapter → RSD → AppService.
// Returns 0 on success; cleans up any partial state and returns 1 on failure.
static int connect_app_service(IdeviceProviderHandle *provider,
AppServiceSession *out,
JITEnableContext *ctx,
NSError **outError)
{
memset(out, 0, sizeof(*out));
IdeviceFfiError *ffiError = NULL;
do {
ffiError = core_device_proxy_connect(providerToUse, &coreProxy);
CoreDeviceProxyHandle *coreProxy = NULL;
ffiError = core_device_proxy_connect(provider, &coreProxy);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
return 1;
}
uint16_t rsdPort = 0;
ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
core_device_proxy_free(coreProxy);
return 1;
}
ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter);
ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &out->adapter);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
core_device_proxy_free(coreProxy);
return 1;
}
coreProxy = NULL; // ownership transferred to adapter
coreProxy = NULL;
ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream);
AdapterStreamHandle *stream = NULL;
ffiError = adapter_connect(out->adapter, rsdPort, (ReadWriteOpaque **)&stream);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
app_service_session_free(out);
return 1;
}
ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake);
ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &out->handshake);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
adapter_stream_close(stream);
app_service_session_free(out);
return 1;
}
stream = NULL; // consumed by handshake
stream = NULL;
ffiError = app_service_connect_rsd(adapter, handshake, &appService);
ffiError = app_service_connect_rsd(out->adapter, out->handshake, &out->appService);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"]
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
app_service_session_free(out);
return 1;
}
ffiError = app_service_list_processes(appService, &processes, &count);
return 0;
}
// MARK: - JITEnableContext(Process)
@implementation JITEnableContext(Process)
- (NSArray<NSDictionary*>*)fetchProcessesViaAppServiceWithError:(NSError **)error {
[self ensureHeartbeatWithError:error];
if (*error) { return nil; }
AppServiceSession session;
if (connect_app_service(provider, &session, self, error) != 0) { return nil; }
ProcessTokenC *processes = NULL;
uintptr_t count = 0;
IdeviceFfiError *ffiError = app_service_list_processes(session.appService, &processes, &count);
NSMutableArray *result = nil;
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
} else {
result = [NSMutableArray arrayWithCapacity:count];
for (uintptr_t idx = 0; idx < count; idx++) {
ProcessTokenC proc = processes[idx];
@ -120,34 +128,18 @@
}
[result addObject:entry];
}
} while (0);
if (processes && count > 0) {
app_service_free_process_list(processes, count);
}
if (appService) {
app_service_free(appService);
}
if (handshake) {
rsd_handshake_free(handshake);
}
if (stream) {
adapter_stream_close(stream);
}
if (adapter) {
adapter_free(adapter);
}
if (coreProxy) {
core_device_proxy_free(coreProxy);
}
app_service_session_free(&session);
return result;
}
- (NSArray<NSDictionary*>*)_fetchProcessListLocked:(NSError**)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return nil;
}
if (*error) { return nil; }
return [self fetchProcessesViaAppServiceWithError:error];
}
@ -165,123 +157,28 @@
- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return nil;
}
if (*error) { return NO; }
AppServiceSession session;
if (connect_app_service(provider, &session, self, error) != 0) { return NO; }
IdeviceProviderHandle *providerToUse = provider;
CoreDeviceProxyHandle *coreProxy = NULL;
AdapterHandle *adapter = NULL;
AdapterStreamHandle *stream = NULL;
RsdHandshakeHandle *handshake = NULL;
AppServiceHandle *appService = NULL;
SignalResponseC *signalResponse = NULL;
IdeviceFfiError *ffiError = NULL;
IdeviceFfiError *ffiError = app_service_send_signal(session.appService, (uint32_t)pid, SIGKILL, &signalResponse);
BOOL success = NO;
do {
ffiError = core_device_proxy_connect(providerToUse, &coreProxy);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
uint16_t rsdPort = 0;
ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
coreProxy = NULL;
ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
stream = NULL;
ffiError = app_service_connect_rsd(adapter, handshake, &appService);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
ffiError = app_service_send_signal(appService, (uint32_t)pid, SIGKILL, &signalResponse);
if (ffiError) {
if (error) {
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"]
code:ffiError->code];
}
idevice_error_free(ffiError);
ffiError = NULL;
break;
}
} else {
success = YES;
} while (0);
}
if (signalResponse) {
app_service_free_signal_response(signalResponse);
}
if (appService) {
app_service_free(appService);
}
if (handshake) {
rsd_handshake_free(handshake);
}
if (stream) {
adapter_stream_close(stream);
}
if (adapter) {
adapter_free(adapter);
}
if (coreProxy) {
core_device_proxy_free(coreProxy);
}
if (signalResponse) { app_service_free_signal_response(signalResponse); }
app_service_session_free(&session);
return success;
}
@end

View file

@ -15,17 +15,17 @@ NSError* makeError(int code, NSString* msg) {
NSArray<NSData*>* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
MisagentClientHandle *misagentHandle = NULL;
IdeviceFfiError *err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return nil;
}
uint8_t** profileArr = 0;
uint8_t **profileArr = NULL;
size_t profileCount = 0;
size_t* profileLengthArr = 0;
size_t *profileLengthArr = NULL;
err = misagent_copy_all(misagentHandle, &profileArr, &profileLengthArr, &profileCount);
if (err) {
@ -51,7 +51,7 @@ NSArray<NSData*>* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** er
}
bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
MisagentClientHandle *misagentHandle = NULL;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
@ -72,7 +72,7 @@ bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** er
}
bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
MisagentClientHandle *misagentHandle = NULL;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
@ -111,30 +111,21 @@ bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** erro
NSData *plistEnd = [@"</plist>" dataUsingEncoding:NSASCIIStringEncoding];
NSData *binaryMagic = [@"bplist00" dataUsingEncoding:NSASCIIStringEncoding];
if (xmlStart && plistEnd) {
NSRange searchRange = NSMakeRange(0, cmsData.length);
NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:searchRange];
NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:NSMakeRange(0, cmsData.length)];
if (startRange.location != NSNotFound) {
NSUInteger remainingLength = cmsData.length - startRange.location;
NSRange endSearchRange = NSMakeRange(startRange.location, remainingLength);
NSRange endSearchRange = NSMakeRange(startRange.location, cmsData.length - startRange.location);
NSRange endRange = [cmsData rangeOfData:plistEnd options:0 range:endSearchRange];
if (endRange.location != NSNotFound) {
NSUInteger plistStart = startRange.location;
NSUInteger plistEndIndex = NSMaxRange(endRange);
if (plistEndIndex > plistStart && plistEndIndex <= cmsData.length) {
NSRange plistRange = NSMakeRange(plistStart, plistEndIndex - plistStart);
return [cmsData subdataWithRange:plistRange];
}
if (plistEndIndex > startRange.location && plistEndIndex <= cmsData.length) {
return [cmsData subdataWithRange:NSMakeRange(startRange.location, plistEndIndex - startRange.location)];
}
}
}
if (binaryMagic) {
NSRange binaryRange = [cmsData rangeOfData:binaryMagic options:0 range:NSMakeRange(0, cmsData.length)];
if (binaryRange.location != NSNotFound) {
NSRange plistRange = NSMakeRange(binaryRange.location, cmsData.length - binaryRange.location);
return [cmsData subdataWithRange:plistRange];
}
return [cmsData subdataWithRange:NSMakeRange(binaryRange.location, cmsData.length - binaryRange.location)];
}
if (error) {
@ -161,7 +152,7 @@ bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** erro
- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return nil;
return NO;
}
return removeProfile(provider, uuid, error);
@ -170,7 +161,7 @@ bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** erro
- (BOOL)addProfile:(NSData*)profile error:(NSError **)error {
[self ensureHeartbeatWithError:error];
if(*error) {
return nil;
return NO;
}
return addProfile(provider, profile, error);
}

View file

@ -1,166 +0,0 @@
//
// something.c
// StikDebug
//
// Created by Stephen on 7/29/25.
//
#include "something.h"
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>
static int mmap_file(const char *path, uint8_t **data, size_t *len)
{
int fd = open(path, O_RDONLY);
if (fd < 0) {
perror("open");
return 0;
}
struct stat st;
if (fstat(fd, &st) != 0) {
perror("fstat");
close(fd);
return 0;
}
*len = (size_t)st.st_size;
*data = mmap(NULL, *len, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (*data == MAP_FAILED) {
perror("mmap");
return 0;
}
return 1;
}
static void munmap_file(uint8_t *data, size_t len)
{
if (data && data != MAP_FAILED) {
munmap(data, len);
}
}
int install_ipa(const char *ip,
const char *pairing_file_path,
const char *udid,
const char *ipa_path)
{
(void)udid;
idevice_init_logger(Debug, Disabled, NULL);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(LOCKDOWN_PORT);
if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) {
fprintf(stderr, "Invalid IP address: %s\n", ip);
return IPA_INSTALLER_ERR_INVALID_IP;
}
IdevicePairingFile *pairing_file = NULL;
IdeviceFfiError *err = idevice_pairing_file_read(pairing_file_path,
&pairing_file);
if (err) {
fprintf(stderr, "Pairing file read failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
return IPA_INSTALLER_ERR_PAIRING_READ;
}
IdeviceProviderHandle *provider = NULL;
err = idevice_tcp_provider_new((struct sockaddr *)&addr,
pairing_file,
"IPAInstaller",
&provider);
if (err) {
fprintf(stderr, "Provider create failed: [%d] %s\n",
err->code, err->message);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return IPA_INSTALLER_ERR_PROVIDER_CREATE;
}
AfcClientHandle *afc = NULL;
err = afc_client_connect(provider, &afc);
if (err) {
fprintf(stderr, "AFC connect failed: [%d] %s\n",
err->code, err->message);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return IPA_INSTALLER_ERR_AFC_CONNECT;
}
uint8_t *ipa_data = NULL;
size_t ipa_len = 0;
if (!mmap_file(ipa_path, &ipa_data, &ipa_len)) {
fprintf(stderr, "Unable to read IPA file\n");
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
return IPA_INSTALLER_ERR_IPA_READ;
}
const char *slash = strrchr(ipa_path, '/');
const char *fname = slash ? slash + 1 : ipa_path;
char dest[256];
snprintf(dest, sizeof(dest), "/PublicStaging/%s", fname);
AfcFileHandle *remote = NULL;
err = afc_file_open(afc, dest, AfcWrOnly, &remote);
if (err) {
fprintf(stderr, "AFC open failed: [%d] %s\n",
err->code, err->message);
munmap_file(ipa_data, ipa_len);
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return IPA_INSTALLER_ERR_AFC_OPEN;
}
err = afc_file_write(remote, ipa_data, ipa_len);
afc_file_close(remote);
munmap_file(ipa_data, ipa_len);
if (err) {
fprintf(stderr, "AFC write failed: [%d] %s\n",
err->code, err->message);
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return IPA_INSTALLER_ERR_AFC_WRITE;
}
InstallationProxyClientHandle *ipc = NULL;
err = installation_proxy_connect(provider, &ipc);
if (err) {
fprintf(stderr, "installation_proxy connect failed: [%d] %s\n",
err->code, err->message);
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
return IPA_INSTALLER_ERR_INSTALLPROXY;
}
err = installation_proxy_install(ipc, dest, NULL);
installation_proxy_client_free(ipc);
afc_client_free(afc);
idevice_provider_free(provider);
if (err) {
fprintf(stderr, "IPA install failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
return IPA_INSTALLER_ERR_INSTALL;
}
fprintf(stderr, "IPA installed successfully\n");
return IPA_INSTALLER_OK;
}

View file

@ -1,32 +0,0 @@
//
// something.h
// StikDebug
//
// Created by Stephen on 7/29/25.
//
#ifndef SOMETHING_H
#define SOMETHING_H
#include "idevice.h"
#include <stdint.h>
typedef enum {
IPA_INSTALLER_OK = 0, /* success */
IPA_INSTALLER_ERR_PAIRING_READ = 1, /* could not read pairing file */
IPA_INSTALLER_ERR_PROVIDER_CREATE = 2, /* idevice_tcp_provider_new fail */
IPA_INSTALLER_ERR_AFC_CONNECT = 3, /* afc_client_connect fail */
IPA_INSTALLER_ERR_IPA_READ = 4, /* failed to mmap or read IPA */
IPA_INSTALLER_ERR_AFC_OPEN = 5, /* afc_file_open fail */
IPA_INSTALLER_ERR_AFC_WRITE = 6, /* afc_file_write fail */
IPA_INSTALLER_ERR_INSTALLPROXY = 7, /* installation_proxy connect */
IPA_INSTALLER_ERR_INSTALL = 8, /* installation_proxy_install */
IPA_INSTALLER_ERR_INVALID_IP = 9, /* inet_pton failed */
} ipa_installer_error_t;
int install_ipa(const char *ip,
const char *pairing_file_path,
const char *udid,
const char *ipa_path);
#endif /* SOMETHING_H */

View file

@ -1,33 +0,0 @@
"Welcome to StikDebug %@!" = "Benvenuto su StikDebug %@!";
"Click connect to get started" = "Clicca su Connetti per cominciare";
"Pick pairing file to get started" = "Seleziona un file di pairing per iniziare";
"Device Not Mounted" = "Dispositivo non montato";
"The Developer Disk Image has not been mounted yet. Check in settings for more information." = "L'Immagine del disco dello sviluppatore non è stata ancora montata. Connettiti al Wi-Fi e riavvia forzatamente StikDebug.";
"Connect by App" = "Connetti dall'App";
"Select Pairing File" = "Seleziona file di Pairing";
"Connect by PID" = "Connetti con PID";
"Open Console" = "Apri Console";
"Processing pairing file..." = "Elaborazione del file di pairing...";
"\u2713 Pairing file successfully imported" = "\u2713 File di Pairing importato correttamente";
"Please enter the PID of the process you want to connect to" = "Per favore inserisci il PID del processo a cui vuoi connetterti";
"Invalid PID" = "PID Invalido";
"Success" = "Successo";
"JIT has been enabled for pid %d." = "JIT è stato abilitato per il pid %d.";
"Installed Apps" = "App Installate";
"No Debuggable App Found" = "Nessuna App Debuggabile trovata";
"StikDebug can only connect to apps with the \"get-task-allow\" entitlement. Please check if the app you want to connect to is signed with a development certificate." = "StikDebug può connettersi solamente ad app con l'entitlement \"get-task-allow\". Per favore verifica che l'app a cui vuoi connetterti è firmata con un certificato di sviluppo.";
"Favorites (%d/4)" = "Preferiti (%d/4)";
"Recents" = "Recenti";
"All Applications" = "Tutte le applicazioni";
"Remove Favorite" = "Rimuovi preferito";
"Add to Favorites" = "Aggiungi ai preferiti";
"Copy Bundle ID" = "Copia il Bundle ID";
"Delete" = "Elimina";
"Loading..." = "Caricamento...";
"Error Occurred While Executing the Default Script." = "Errore durante l'esecuzione dello Script di Default.";
"Unsupported OS Version" = "Versione non supportata";
"StikJIT only supports 17.4 and above. Your device is running iOS/iPadOS %@" = "StikJIT supporta solamente 17.4 e successivi. Il tuo dispositivo ha iOS/iPadOS %@";
"Cancel" = "Cancella";
"OK" = "OK";
"Enable Advanced Options" = "Abilita Opzioni Avanzate";