mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
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:
parent
7b103175b2
commit
f3d2dec2e5
75 changed files with 2923 additions and 10252 deletions
66
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
66
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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.
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
21
.github/pull_request_template.md
vendored
Normal 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 -->
|
||||
6
.github/workflows/build_ipa.yml
vendored
6
.github/workflows/build_ipa.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
137
README.md
137
README.md
|
|
@ -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,34 +15,151 @@
|
|||
<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.
|
||||
|
||||
<div align="center" style="display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap;">
|
||||
<a href="https://stikstore.app/altdirect/?url=https://stikdebug.xyz/index.json" target="_blank">
|
||||
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/AltSource_Blue.png" alt="Add AltSource" width="200">
|
||||
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/AltSource_Blue.png" alt="Add AltSource" width="200">
|
||||
</a>
|
||||
<a href="https://github.com/StephenDev0/StikDebug/releases/download/2.3.7/StikDebug-2.3.7.ipa" target="_blank">
|
||||
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/Download_Blue.png" alt="Download .ipa" width="200">
|
||||
<img src="https://github.com/stikstore/altdirect/blob/main/assets/png/Download_Blue.png" alt="Download .ipa" width="200">
|
||||
</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
|
||||
[](https://deepwiki.com/stephendev0/stikdebug)
|
||||
|
||||
## License
|
||||
## License
|
||||
StikDebug is licensed under **AGPL-3.0**. See [`LICENSE`](LICENSE) for details.
|
||||
|
|
|
|||
|
|
@ -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 */;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -74,9 +74,6 @@
|
|||
ReferencedContainer = "container:StikDebug.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../StikDebug.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>location</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,8 +52,7 @@ struct ScriptEditorView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(preferredScheme)
|
||||
.tint(colorScheme == .dark ? .white : .black)
|
||||
.tint(colorScheme == .dark ? .white : .black)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,73 +35,75 @@ struct ScriptListView: View {
|
|||
guard !searchText.isEmpty else { return scripts }
|
||||
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
|
||||
|
||||
if filteredScripts.isEmpty {
|
||||
emptyCard
|
||||
} else {
|
||||
ForEach(filteredScripts, id: \.self) { script in
|
||||
scriptRow(script)
|
||||
}
|
||||
List {
|
||||
if isPickerMode {
|
||||
Section {
|
||||
Button {
|
||||
onSelectScript?(nil)
|
||||
} label: {
|
||||
Label("No Script", systemImage: "nosign")
|
||||
}
|
||||
}
|
||||
.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)
|
||||
)
|
||||
)
|
||||
.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)
|
||||
if filteredScripts.isEmpty {
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: justCopied)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search scripts…"
|
||||
)
|
||||
.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,163 +139,65 @@ struct ScriptListView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(preferredScheme)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(glassyBackground)
|
||||
.alert(alertTitle, isPresented: $alertVisible) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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")
|
||||
HStack {
|
||||
Label(script.lastPathComponent, systemImage: "doc.text.fill")
|
||||
Spacer()
|
||||
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
|
||||
|
||||
private func scriptsDirectory() -> URL {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:)))!
|
||||
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
|
||||
if UserDefaults.standard.bool(forKey: "keepAliveAudio") {
|
||||
BackgroundAudioManager.shared.start()
|
||||
}
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
|
|
@ -345,77 +158,31 @@ struct HeartbeatApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
private var globalAccent: Color {
|
||||
themeExpansionManager.resolvedAccentColor(from: customAccentColorHex)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
BackgroundContainer {
|
||||
MainTabView()
|
||||
.onAppear {
|
||||
Task {
|
||||
let fileManager = FileManager.default
|
||||
for item in ddiDownloadItems {
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
if fileManager.fileExists(atPath: destinationURL.path) { continue }
|
||||
do {
|
||||
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
|
||||
}
|
||||
break
|
||||
MainTabView()
|
||||
.onAppear {
|
||||
Task {
|
||||
let fileManager = FileManager.default
|
||||
for item in ddiDownloadItems {
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
if fileManager.fileExists(atPath: destinationURL.path) { continue }
|
||||
do {
|
||||
try await downloadFile(from: item.urlString, to: destinationURL)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
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: {
|
||||
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: primaryButtonText ?? "Try Again", style: .default) { _ in
|
||||
completion?(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)
|
||||
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)
|
||||
alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in
|
||||
completion?(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
114
StikJIT/Utilities/BackgroundAudioManager.swift
Normal file
114
StikJIT/Utilities/BackgroundAudioManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
56
StikJIT/Utilities/BackgroundLocationManager.swift
Normal file
56
StikJIT/Utilities/BackgroundLocationManager.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,13 +33,14 @@ 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())
|
||||
|
||||
|
||||
await MainActor.run {
|
||||
self.lockdownHandle = lockdownHandle
|
||||
self.initialized = true
|
||||
|
|
@ -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,64 +288,14 @@ 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
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
412
StikJIT/Utilities/Intents.swift
Normal file
412
StikJIT/Utilities/Intents.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,97 +7,96 @@
|
|||
|
||||
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 {
|
||||
case info = "INFO"
|
||||
case error = "ERROR"
|
||||
case debug = "DEBUG"
|
||||
|
||||
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 static let redundantPrefixes: [String] = [
|
||||
"Info: ", "INFO: ", "Information: ",
|
||||
"Error: ", "ERROR: ", "ERR: ",
|
||||
"Debug: ", "DEBUG: ", "DBG: ",
|
||||
"Warning: ", "WARN: ", "WARNING: "
|
||||
]
|
||||
|
||||
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 = [
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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 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 clearLogs() {
|
||||
DispatchQueue.main.async {
|
||||
self.logs.removeAll()
|
||||
self.errorCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
ids.append("profiles")
|
||||
ids.append("processes")
|
||||
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] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,144 +26,106 @@ 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
|
||||
}
|
||||
|
||||
Group {
|
||||
if selectedConsoleTab == .idevice {
|
||||
jitLogsPane
|
||||
} else {
|
||||
syslogLogsPane
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Group {
|
||||
if selectedConsoleTab == .idevice {
|
||||
jitFooter
|
||||
} else {
|
||||
syslogFooter
|
||||
}
|
||||
}
|
||||
NavigationStack {
|
||||
Group {
|
||||
if selectedConsoleTab == .idevice {
|
||||
jitLogsPane
|
||||
} else {
|
||||
syslogLogsPane
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.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
|
||||
)
|
||||
)
|
||||
.navigationTitle("Console")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Picker("", selection: $selectedConsoleTab) {
|
||||
Text("App").tag(ConsoleTab.idevice)
|
||||
Text("System").tag(ConsoleTab.syslog)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Text("Choose how quickly new relay entries appear.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(syslogIntervalOptions, id: \.self) { option in
|
||||
Button {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 30)
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert(alertTitle, isPresented: $showingCustomAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.confirmationDialog("Syslog Speed", isPresented: $showingSyslogSpeedSelector) {
|
||||
ForEach(syslogIntervalOptions, id: \.self) { option in
|
||||
Button(intervalLabel(for: option)) {
|
||||
systemLogStream.updateInterval = option
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Choose how quickly new relay entries appear.")
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.preferredColorScheme(preferredScheme)
|
||||
.onDisappear {
|
||||
.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,241 +197,108 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
|
||||
private var syslogLogsPane: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(systemLogStream.entries) { entry in
|
||||
Text(AttributedString(createSyslogAttributedString(entry)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
.id(entry.id)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named("syslogScroll")).minY
|
||||
)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(filteredSyslogEntries) { entry in
|
||||
Text(AttributedString(createSyslogAttributedString(entry)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
.id(entry.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.coordinateSpace(name: "syslogScroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||
syslogIsAtBottom = offset > -20
|
||||
}
|
||||
.onChange(of: systemLogStream.entries.count) { _ in
|
||||
guard syslogIsAtBottom, let lastLog = systemLogStream.entries.last else { return }
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastLog.id, anchor: .bottom)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named("syslogScroll")).minY
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if selectedConsoleTab == .syslog && !systemLogStream.isStreaming {
|
||||
systemLogStream.start()
|
||||
.coordinateSpace(name: "syslogScroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||
syslogIsAtBottom = offset > -20
|
||||
}
|
||||
.onChange(of: systemLogStream.entries.count) { _ in
|
||||
guard syslogIsAtBottom, syslogSearchText.isEmpty,
|
||||
let lastLog = systemLogStream.entries.last else { return }
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastLog.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if selectedConsoleTab == .syslog && !systemLogStream.isStreaming {
|
||||
systemLogStream.start()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
systemLogStream.stop()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
systemLogStream.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
|
@ -579,9 +405,9 @@ struct ConsoleLogsView: View {
|
|||
private func loadIdeviceLogsAsync() async {
|
||||
guard !isLoadingLogs else { return }
|
||||
isLoadingLogs = true
|
||||
|
||||
|
||||
let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path
|
||||
|
||||
|
||||
guard FileManager.default.fileExists(atPath: logPath) else {
|
||||
await MainActor.run {
|
||||
logManager.addInfoLog("No idevice logs found (Restart the app to continue reading)")
|
||||
|
|
@ -589,53 +415,55 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
lastProcessedLineCount = lines.count
|
||||
|
||||
await MainActor.run {
|
||||
logManager.clearLogs()
|
||||
|
||||
|
||||
// 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 = lines[startIndex..<lines.count]
|
||||
|
||||
let skipPrefixes = ["=== DEVICE INFORMATION ===", "Version:", "Name:", "Model:", "=== LOG ENTRIES ==="]
|
||||
|
||||
var parsed: [LogManager.LogEntry] = []
|
||||
parsed.reserveCapacity(recentLines.count)
|
||||
|
||||
for line in recentLines {
|
||||
if line.isEmpty { continue }
|
||||
|
||||
if line.contains("=== DEVICE INFORMATION ===") ||
|
||||
line.contains("Version:") ||
|
||||
line.contains("Name:") ||
|
||||
line.contains("Model:") ||
|
||||
line.contains("=== LOG ENTRIES ===") {
|
||||
continue
|
||||
}
|
||||
|
||||
if skipPrefixes.contains(where: { line.contains($0) }) { 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
|
||||
}
|
||||
}
|
||||
|
|
@ -655,55 +483,63 @@ struct ConsoleLogsView: View {
|
|||
private func checkForNewLogs() async {
|
||||
guard !isLoadingLogs else { return }
|
||||
isLoadingLogs = true
|
||||
|
||||
|
||||
let logPath = URL.documentsDirectory.appendingPathComponent("idevice_log.txt").path
|
||||
|
||||
let previousCount = lastProcessedLineCount
|
||||
|
||||
guard FileManager.default.fileExists(atPath: logPath) else {
|
||||
isLoadingLogs = false
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
await MainActor.run {
|
||||
for line in newLines {
|
||||
if line.isEmpty { continue }
|
||||
|
||||
if line.contains("ERROR") || line.contains("Error") {
|
||||
logManager.addErrorLog(line)
|
||||
} else if line.contains("WARNING") || line.contains("Warning") {
|
||||
logManager.addWarningLog(line)
|
||||
} else if line.contains("DEBUG") {
|
||||
logManager.addDebugLog(line)
|
||||
} else {
|
||||
logManager.addInfoLog(line)
|
||||
}
|
||||
}
|
||||
|
||||
let maxLines = 500
|
||||
if logManager.logs.count > maxLines {
|
||||
let excessCount = logManager.logs.count - maxLines
|
||||
logManager.removeOldestLogs(count: excessCount)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
guard lines.count > previousCount else { return ([], lines.count) }
|
||||
|
||||
let newLines = lines[previousCount..<lines.count]
|
||||
var parsed: [LogManager.LogEntry] = []
|
||||
parsed.reserveCapacity(newLines.count)
|
||||
|
||||
for line in newLines {
|
||||
if line.isEmpty { continue }
|
||||
|
||||
let type: LogManager.LogEntry.LogType
|
||||
if line.contains("ERROR") || line.contains("Error") {
|
||||
type = .error
|
||||
} else if line.contains("WARNING") || line.contains("Warning") {
|
||||
type = .warning
|
||||
} else if line.contains("DEBUG") {
|
||||
type = .debug
|
||||
} else {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
isLoadingLogs = false
|
||||
}
|
||||
|
||||
private func stopLogCheckTimer() {
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,48 +161,51 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
ThemedBackground(style: backgroundStyle)
|
||||
.ignoresSafeArea()
|
||||
|
||||
tabbedContent
|
||||
.transition(.opacity)
|
||||
.transaction { t in t.disablesAnimations = true }
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
.foregroundStyle(feedback.success ? .green : .red)
|
||||
.shadow(radius: 4)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 40)
|
||||
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)
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Installed Apps".localized)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.onAppear {
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
.preferredColorScheme(preferredScheme)
|
||||
.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))
|
||||
.foregroundStyle(feedback.success ? .green : .red)
|
||||
.shadow(radius: 4)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id)
|
||||
}
|
||||
}
|
||||
.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,289 +254,135 @@ 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 {
|
||||
Section {
|
||||
Text(error).font(.footnote).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let error = viewModel.lastError {
|
||||
sectionCard {
|
||||
errorBanner(error)
|
||||
}
|
||||
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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filteredDebuggableApps.isEmpty {
|
||||
sectionCard {
|
||||
debuggableSearchEmptyState
|
||||
}
|
||||
} else {
|
||||
if !filteredFavoriteBundles.isEmpty {
|
||||
sectionCard(title: String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) {
|
||||
LazyVStack(spacing: 12) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
if !filteredRecentBundles.isEmpty {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !filteredRecentBundles.isEmpty {
|
||||
sectionCard(title: "Recents".localized) {
|
||||
LazyVStack(spacing: 12) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sectionCard(title: "All Applications".localized) {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: appName,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
onSelectApp: onSelectApp,
|
||||
sharedDefaults: sharedDefaults,
|
||||
performanceMode: performanceMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
|
||||
case .launch:
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 18, pinnedViews: []) {
|
||||
sectionCard {
|
||||
launchSearchBar
|
||||
List {
|
||||
if let error = viewModel.lastError {
|
||||
Section {
|
||||
Text(error).font(.footnote).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if let error = viewModel.lastError {
|
||||
sectionCard {
|
||||
errorBanner(error)
|
||||
}
|
||||
if filteredLaunchApps.isEmpty {
|
||||
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)
|
||||
}
|
||||
|
||||
if filteredLaunchApps.isEmpty {
|
||||
sectionCard {
|
||||
Group {
|
||||
if launchSearchIsActive {
|
||||
launchSearchEmptyState
|
||||
} else {
|
||||
emptyState(for: .launch)
|
||||
} else {
|
||||
Section("Other Apps".localized) {
|
||||
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
|
||||
let isPinned = pinnedSystemApps.contains(bundleID)
|
||||
LaunchAppRow(
|
||||
bundleID: bundleID, appName: appName,
|
||||
isLaunching: launchingBundles.contains(bundleID),
|
||||
performanceMode: performanceMode
|
||||
) { startLaunching(bundleID: bundleID) }
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPinned {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.yellow).padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sectionCard(title: "Launchable Apps".localized) {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
|
||||
let isPinned = pinnedSystemApps.contains(bundleID)
|
||||
LaunchAppRow(
|
||||
bundleID: bundleID,
|
||||
appName: appName,
|
||||
isLaunching: launchingBundles.contains(bundleID),
|
||||
performanceMode: performanceMode
|
||||
) {
|
||||
startLaunching(bundleID: bundleID)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPinned {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.yellow)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
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") {
|
||||
UIPasteboard.general.string = bundleID
|
||||
Haptics.light()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||||
} label: {
|
||||
Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star")
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
.contextMenu {
|
||||
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") {
|
||||
UIPasteboard.general.string = bundleID
|
||||
Haptics.light()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||||
} label: {
|
||||
Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star")
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
private func persistIfChanged() {
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
searchCard
|
||||
mapCard
|
||||
actionsCard
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Location Simulator")
|
||||
.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()
|
||||
}
|
||||
ZStack(alignment: .bottom) {
|
||||
MapReader { proxy in
|
||||
Map(position: $position) {
|
||||
if let coordinate {
|
||||
Marker("Pin", coordinate: coordinate)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
if !pairingExists && !isAppStoreBuild {
|
||||
Label("Import a pairing file first.", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.mapStyle(.standard(elevation: .realistic))
|
||||
.onTapGesture { point in
|
||||
if let loc = proxy.convert(point, from: .local) {
|
||||
coordinate = loc
|
||||
}
|
||||
}
|
||||
if isAppStoreBuild {
|
||||
Label("Location simulation is unavailable in App Store builds.", systemImage: "nosign")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.mapControls {
|
||||
MapCompass()
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onChange(of: coordinate) { _, new in
|
||||
if let new {
|
||||
position = .region(MKCoordinateRegion(center: new, latitudinalMeters: 1000, longitudinalMeters: 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
VStack(spacing: 12) {
|
||||
if let coord = coordinate {
|
||||
Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.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("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)
|
||||
Text("Tap map to drop pin")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(statusIsError ? .red : .green)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Device IP: \(deviceIP)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert(alertTitle, isPresented: $showAlert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(alertMessage)
|
||||
}
|
||||
.onDisappear {
|
||||
stopResendLoop()
|
||||
endBackgroundTask()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
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 {
|
||||
beginBackgroundTask()
|
||||
startResendLoop()
|
||||
} else {
|
||||
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()
|
||||
if code == 0 {
|
||||
recordPiPEvent(status: "Simulation cleared", coordinate: nil)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
dismissPiPSession()
|
||||
Self.locationQueue.async {
|
||||
let code = clear_simulated_location()
|
||||
DispatchQueue.main.async {
|
||||
isBusy = false
|
||||
if code == 0 {
|
||||
coordinate = nil
|
||||
endBackgroundTask()
|
||||
} else {
|
||||
alertTitle = "Clear Failed"
|
||||
alertMessage = "Could not clear simulated location (error \(code))."
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dismissPiPSession()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
if let coord = coordinate {
|
||||
recordPiPEvent(status: "Simulating…", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,32 +9,22 @@ 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) {
|
||||
Button(action: viewModel.refresh) {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
content
|
||||
.navigationTitle("Process Inspector")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: viewModel.refresh) {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(viewModel.isRefreshing)
|
||||
}
|
||||
.disabled(viewModel.isRefreshing)
|
||||
}
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
}
|
||||
.task {
|
||||
await viewModel.startAutoRefresh()
|
||||
|
|
@ -42,104 +32,51 @@ 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()
|
||||
List {
|
||||
Section("Overview") {
|
||||
LabeledContent("Total Processes") {
|
||||
Text("\(viewModel.processes.count)")
|
||||
.font(.title2.bold())
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
statsCard
|
||||
processesCard
|
||||
Section("Processes") {
|
||||
if viewModel.filteredProcesses.isEmpty {
|
||||
Text("No matching processes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.filteredProcesses) { process in
|
||||
ProcessRow(
|
||||
process: process,
|
||||
isKilling: viewModel.killingPID == process.pid,
|
||||
isConfirming: killCandidate?.pid == process.pid,
|
||||
onKillTap: { handleKillTap(for: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.refreshable {
|
||||
viewModel.refresh()
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { viewModel.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
Text("\(viewModel.processes.count)")
|
||||
.font(.title2.bold())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var processesCard: some View {
|
||||
MaterialCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Processes")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
if viewModel.filteredProcesses.isEmpty {
|
||||
Text("No matching processes.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewModel.filteredProcesses) { process in
|
||||
ProcessRow(
|
||||
process: process,
|
||||
isKilling: viewModel.killingPID == process.pid,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ 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
|
||||
do {
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
} else if entries.isEmpty && notMatchedProfiles.isEmpty {
|
||||
Section {
|
||||
Text("No profiles found.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
if alert {
|
||||
CustomErrorView(title: alertTitle,
|
||||
message: alertMsg,
|
||||
onDismiss: { alert = false },
|
||||
messageType: alertSuccess ? .success : .error)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.alert(alertTitle, isPresented: $alert) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(alertMsg)
|
||||
}
|
||||
}
|
||||
|
||||
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("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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
50
StikJIT/Views/ToolsView.swift
Normal file
50
StikJIT/Views/ToolsView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,96 +20,76 @@ 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;
|
||||
|
||||
*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);
|
||||
idevice_error_free(err);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -35,35 +88,33 @@ void runDebugServerCommand(int pid,
|
|||
logger("QStartNoAckMode result = %s, err = %d", disableResponse, err);
|
||||
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);
|
||||
|
||||
|
||||
DebugserverCommandHandle *attach_cmd = debugserver_command_new(attach_command, NULL, 0);
|
||||
if (attach_cmd == NULL) {
|
||||
logger("Failed to create attach command");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
DebugSession session;
|
||||
if (connect_debug_session(tcp_provider, &session) != 0) 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);
|
||||
idevice_error_free(err);
|
||||
remote_server_free(remote_server);
|
||||
return 1;
|
||||
IdeviceFfiError *err = process_control_new(session.remote_server, &process_control);
|
||||
if (err) {
|
||||
idevice_error_free(err);
|
||||
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);
|
||||
idevice_error_free(err);
|
||||
process_control_free(process_control);
|
||||
remote_server_free(remote_server);
|
||||
return 1;
|
||||
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);
|
||||
debug_session_free(&session);
|
||||
return 1;
|
||||
}
|
||||
printf("Successfully launched app with PID: %llu\n", pid);
|
||||
|
||||
printf("\n=== Setting up Debug Proxy ===\n");
|
||||
runDebugServerCommand((int)pid, session.debug_proxy, session.remote_server, logger, callback);
|
||||
|
||||
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((int)pid, debug_proxy, 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;
|
||||
|
||||
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;
|
||||
}
|
||||
DebugSession session;
|
||||
if (connect_debug_session(tcp_provider, &session) != 0) 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);
|
||||
runDebugServerCommand(pid, session.debug_proxy, session.remote_server, logger, callback);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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 */
|
||||
|
|
@ -10,79 +10,83 @@
|
|||
@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);
|
||||
idevice_error_free(err);
|
||||
return 6;
|
||||
}
|
||||
|
||||
|
||||
err = lockdownd_start_session(lockdownClient, pairingFile2);
|
||||
idevice_pairing_file_free(pairingFile2);
|
||||
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);
|
||||
|
||||
ImageMounterHandle* mounterClient = 0;
|
||||
plist_free(uniqueChipIDPlist);
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,107 +9,115 @@
|
|||
#import "JITEnableContextInternal.h"
|
||||
@import Foundation;
|
||||
|
||||
// MARK: - Shared AppService session
|
||||
|
||||
typedef struct {
|
||||
AdapterHandle *adapter;
|
||||
RsdHandshakeHandle *handshake;
|
||||
AppServiceHandle *appService;
|
||||
} AppServiceSession;
|
||||
|
||||
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;
|
||||
|
||||
CoreDeviceProxyHandle *coreProxy = NULL;
|
||||
ffiError = core_device_proxy_connect(provider, &coreProxy);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint16_t rsdPort = 0;
|
||||
ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
core_device_proxy_free(coreProxy);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &out->adapter);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
core_device_proxy_free(coreProxy);
|
||||
return 1;
|
||||
}
|
||||
coreProxy = NULL; // ownership transferred to adapter
|
||||
|
||||
AdapterStreamHandle *stream = NULL;
|
||||
ffiError = adapter_connect(out->adapter, rsdPort, (ReadWriteOpaque **)&stream);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
app_service_session_free(out);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &out->handshake);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
adapter_stream_close(stream);
|
||||
app_service_session_free(out);
|
||||
return 1;
|
||||
}
|
||||
stream = NULL; // consumed by handshake
|
||||
|
||||
ffiError = app_service_connect_rsd(out->adapter, out->handshake, &out->appService);
|
||||
if (ffiError) {
|
||||
*outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"]
|
||||
code:ffiError->code];
|
||||
idevice_error_free(ffiError);
|
||||
app_service_session_free(out);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// MARK: - JITEnableContext(Process)
|
||||
|
||||
@implementation JITEnableContext(Process)
|
||||
|
||||
- (NSArray<NSDictionary*>*)fetchProcessesViaAppServiceWithError:(NSError **)error {
|
||||
[self ensureHeartbeatWithError:error];
|
||||
if(*error) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
IdeviceProviderHandle *providerToUse = provider;
|
||||
CoreDeviceProxyHandle *coreProxy = NULL;
|
||||
AdapterHandle *adapter = NULL;
|
||||
AdapterStreamHandle *stream = NULL;
|
||||
RsdHandshakeHandle *handshake = NULL;
|
||||
AppServiceHandle *appService = NULL;
|
||||
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;
|
||||
IdeviceFfiError *ffiError = NULL;
|
||||
|
||||
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;
|
||||
if (ffiError) {
|
||||
if (error) {
|
||||
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"]
|
||||
code:ffiError->code];
|
||||
}
|
||||
|
||||
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_list_processes(appService, &processes, &count);
|
||||
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;
|
||||
}
|
||||
|
||||
idevice_error_free(ffiError);
|
||||
} 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 (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;
|
||||
}
|
||||
|
||||
IdeviceProviderHandle *providerToUse = provider;
|
||||
CoreDeviceProxyHandle *coreProxy = NULL;
|
||||
AdapterHandle *adapter = NULL;
|
||||
AdapterStreamHandle *stream = NULL;
|
||||
RsdHandshakeHandle *handshake = NULL;
|
||||
AppServiceHandle *appService = NULL;
|
||||
if (*error) { return NO; }
|
||||
|
||||
AppServiceSession session;
|
||||
if (connect_app_service(provider, &session, self, error) != 0) { return NO; }
|
||||
|
||||
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;
|
||||
if (ffiError) {
|
||||
if (error) {
|
||||
*error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"]
|
||||
code:ffiError->code];
|
||||
}
|
||||
idevice_error_free(ffiError);
|
||||
} 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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -107,34 +107,25 @@ bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** erro
|
|||
return nil;
|
||||
}
|
||||
|
||||
NSData *xmlStart = [@"<?xml" dataUsingEncoding:NSASCIIStringEncoding];
|
||||
NSData *plistEnd = [@"</plist>" dataUsingEncoding:NSASCIIStringEncoding];
|
||||
NSData *xmlStart = [@"<?xml" dataUsingEncoding:NSASCIIStringEncoding];
|
||||
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];
|
||||
if (startRange.location != NSNotFound) {
|
||||
NSUInteger remainingLength = cmsData.length - startRange.location;
|
||||
NSRange endSearchRange = NSMakeRange(startRange.location, remainingLength);
|
||||
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];
|
||||
}
|
||||
NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:NSMakeRange(0, cmsData.length)];
|
||||
if (startRange.location != NSNotFound) {
|
||||
NSRange endSearchRange = NSMakeRange(startRange.location, cmsData.length - startRange.location);
|
||||
NSRange endRange = [cmsData rangeOfData:plistEnd options:0 range:endSearchRange];
|
||||
if (endRange.location != NSNotFound) {
|
||||
NSUInteger plistEndIndex = NSMaxRange(endRange);
|
||||
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];
|
||||
}
|
||||
NSRange binaryRange = [cmsData rangeOfData:binaryMagic options:0 range:NSMakeRange(0, cmsData.length)];
|
||||
if (binaryRange.location != NSNotFound) {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue