diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml
index 9836e3e7..23a7ee62 100644
--- a/.github/workflows/build_ipa.yml
+++ b/.github/workflows/build_ipa.yml
@@ -9,11 +9,13 @@ on:
permissions:
contents: write
-
+
jobs:
build:
name: Build Debug IPA
runs-on: macos-latest
+ env:
+ UPLOAD_IPA: ${{ vars.UPLOAD_IPA || 'false' }}
steps:
- name: Checkout
@@ -49,6 +51,7 @@ jobs:
rm -rf Payload StikDebug.app
- name: Upload Debug IPA (artifact)
+ if: env.UPLOAD_IPA == 'true'
uses: actions/upload-artifact@v4
with:
name: StikDebug-Debug.ipa
@@ -56,7 +59,7 @@ jobs:
retention-days: 90
- name: Create or Update GitHub Release (GitHub-Alpha)
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ if: env.UPLOAD_IPA == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: GitHub-Alpha
diff --git a/DebugWidget/DebugWidgetBundle.swift b/DebugWidget/DebugWidgetBundle.swift
index 10b39be2..77bf06ed 100644
--- a/DebugWidget/DebugWidgetBundle.swift
+++ b/DebugWidget/DebugWidgetBundle.swift
@@ -11,7 +11,7 @@ import SwiftUI
@main
struct StikDebugWidgetBundle: WidgetBundle {
var body: some Widget {
- // Both widgets enabled: Favorites uses enable-jit URL scheme (with continued processing handled in-app),
+ // 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()
diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj
index 966ad405..ead89f9f 100644
--- a/StikDebug.xcodeproj/project.pbxproj
+++ b/StikDebug.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ 17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; };
68D1FA402E847E4A0028A0EA /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68D1FA3F2E847E4A0028A0EA /* StoreKit.framework */; };
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; };
@@ -153,6 +154,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 17C744F02E20BED000834F17 /* Pipify in Frameworks */,
DCBA85862E3897BD00E88C06 /* StikImporter in Frameworks */,
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */,
68D1FA402E847E4A0028A0EA /* StoreKit.framework in Frameworks */,
@@ -270,6 +272,7 @@
packageProductDependencies = (
68D569BD2E1B415700A5BA36 /* CodeEditorView */,
68D569BF2E1B415700A5BA36 /* LanguageSupport */,
+ 17C744EF2E20BED000834F17 /* Pipify */,
DCBA85852E3897BD00E88C06 /* StikImporter */,
68E714E52E6AA2B00025610F /* ZIPFoundation */,
);
@@ -382,11 +385,13 @@
en,
Base,
es,
+ it,
);
mainGroup = DC6F1D2E2D94EADD0071B2B6;
minimizedProjectReferenceProxies = 1;
packageReferences = (
68D569BC2E1B415700A5BA36 /* XCRemoteSwiftPackageReference "CodeEditorView" */,
+ 17C744EE2E20BED000834F17 /* XCRemoteSwiftPackageReference "swiftui-pipify" */,
DCBA85842E3897BD00E88C06 /* XCRemoteSwiftPackageReference "StikImporter" */,
68E714E42E6AA2B00025610F /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
);
@@ -699,7 +704,9 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StikJIT/Info.plist;
+ 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_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -755,7 +762,9 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StikJIT/Info.plist;
+ 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_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -997,6 +1006,14 @@
/* 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";
@@ -1024,6 +1041,11 @@
/* 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" */;
diff --git a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index ce50db7b..7da08b05 100644
--- a/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/StikDebug.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "0c22ab9d76f71ac04ee3ae6420154b1551511330120649c0ec71bbeb02f33fd5",
+ "originHash" : "ac6355f0d394d08e98cd8bc09669552151ca717b83a414b5708b301c9ba767e4",
"pins" : [
{
"identity" : "codeeditorview",
@@ -28,6 +28,15 @@
"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",
diff --git a/StikJIT/script2.js b/StikJIT/Amethyst.js
similarity index 100%
rename from StikJIT/script2.js
rename to StikJIT/Amethyst.js
diff --git a/StikJIT/Info.plist b/StikJIT/Info.plist
index 27dbccf4..9b96e3b4 100644
--- a/StikJIT/Info.plist
+++ b/StikJIT/Info.plist
@@ -15,17 +15,11 @@
- BGTaskSchedulerPermittedIdentifiers
+ NSBonjourServices
- $(PRODUCT_BUNDLE_IDENTIFIER).continuedProcessingTask.script
+ _stikdebug._tcp
+ _stikdebug._udp
- UIBackgroundModes
-
- audio
- processing
-
- ITSAppUsesNonExemptEncryption
-
UIFileSharingEnabled
diff --git a/StikJIT/JSSupport/RunJSView.swift b/StikJIT/JSSupport/RunJSView.swift
index d09d7133..8e994e7c 100644
--- a/StikJIT/JSSupport/RunJSView.swift
+++ b/StikJIT/JSSupport/RunJSView.swift
@@ -8,6 +8,9 @@
import SwiftUI
import JavaScriptCore
+typealias RemoteServerHandle = OpaquePointer
+typealias ScreenshotClientHandle = OpaquePointer
+
class RunJSViewModel: ObservableObject {
var context: JSContext?
@Published var logs: [String] = []
@@ -15,13 +18,13 @@ class RunJSViewModel: ObservableObject {
@Published var executionInterrupted = false
var pid: Int
var debugProxy: OpaquePointer?
+ var remoteServer: OpaquePointer?
var semaphore: dispatch_semaphore_t?
- private var progressTimer: DispatchSourceTimer?
- private var reportedProgress: Double = 0
- init(pid: Int, debugProxy: OpaquePointer?, semaphore: dispatch_semaphore_t?) {
+ init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: dispatch_semaphore_t?) {
self.pid = pid
self.debugProxy = debugProxy
+ self.remoteServer = remoteServer
self.semaphore = semaphore
}
@@ -32,7 +35,6 @@ class RunJSViewModel: ObservableObject {
func runScript(data: Data, name: String? = nil) throws {
let scriptContent = String(data: data, encoding: .utf8)
scriptName = name ?? "Script"
- startContinuedProcessing(withTitle: scriptName)
let getPidFunction: @convention(block) () -> Int = {
return self.pid
@@ -61,6 +63,10 @@ class RunJSViewModel: ObservableObject {
return handleJITPageWrite(self.context, startAddr, regionSize, self.debugProxy) ?? ""
}
+ let takeScreenshotFunction: @convention(block) (String?) -> String? = { fileName in
+ return self.captureScreenshot(named: fileName)
+ }
+
let hasTXMFunction: @convention(block) () -> Bool = {
return ProcessInfo.processInfo.hasTXM
}
@@ -70,6 +76,7 @@ class RunJSViewModel: ObservableObject {
context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString)
context?.setObject(sendCommandFunction, forKeyedSubscript: "send_command" as NSString)
context?.setObject(prepareMemoryRegionFunction, forKeyedSubscript: "prepare_memory_region" as NSString)
+ context?.setObject(takeScreenshotFunction, forKeyedSubscript: "take_screenshot" as NSString)
context?.setObject(logFunction, forKeyedSubscript: "log" as NSString)
context?.evaluateScript(scriptContent)
@@ -81,43 +88,139 @@ class RunJSViewModel: ObservableObject {
if let exception = self.context?.exception {
self.logs.append(exception.debugDescription)
}
- let success = self.context?.exception == nil && !self.executionInterrupted
- self.stopContinuedProcessing(success: success)
self.logs.append("Script Execution Completed")
- self.logs.append("Background processing finished. You can dismiss this view.")
+ self.logs.append("You are safe to close the PIP Window.")
+ }
+ }
+
+ private func captureScreenshot(named preferredName: String?) -> String {
+ if executionInterrupted {
+ raiseException("Script execution is interrupted by StikDebug.")
+ return ""
+ }
+ guard let remoteServer else {
+ raiseException("Screenshot capture is unavailable in the current session.")
+ return ""
+ }
+
+ var screenshotClient: ScreenshotClientHandle?
+ let creationError = screenshot_client_new(remoteServer, &screenshotClient)
+ if let creationError {
+ let message = describeIdeviceError(creationError)
+ idevice_error_free(creationError)
+ raiseException("Failed to create screenshot client: \(message)")
+ return ""
+ }
+ guard let screenshotClient else {
+ raiseException("Failed to allocate screenshot client.")
+ return ""
+ }
+ defer { screenshot_client_free(screenshotClient) }
+
+ var buffer: UnsafeMutablePointer?
+ var length: UInt = 0
+ let captureError = screenshot_client_take_screenshot(screenshotClient, &buffer, &length)
+ if let captureError {
+ let message = describeIdeviceError(captureError)
+ idevice_error_free(captureError)
+ raiseException("Failed to take screenshot: \(message)")
+ return ""
+ }
+ guard let buffer else {
+ raiseException("Device returned empty screenshot data.")
+ return ""
+ }
+ defer { idevice_data_free(buffer, length) }
+
+ let data = Data(bytes: buffer, count: Int(length))
+ do {
+ let fileURL = try screenshotFileURL(preferredName: preferredName)
+ try data.write(to: fileURL, options: .atomic)
+ return fileURL.path
+ } catch {
+ raiseException("Failed to save screenshot: \(error.localizedDescription)")
+ return ""
+ }
+ }
+
+ private func screenshotFileURL(preferredName: String?) throws -> URL {
+ let directory = URL.documentsDirectory.appendingPathComponent("screenshots", isDirectory: true)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let fileManager = FileManager.default
+ let initialName = sanitizedScreenshotName(from: preferredName)
+ var targetURL = directory.appendingPathComponent(initialName)
+ guard fileManager.fileExists(atPath: targetURL.path) else {
+ return targetURL
}
+
+ let baseName = targetURL.deletingPathExtension().lastPathComponent
+ let ext = targetURL.pathExtension.isEmpty ? "png" : targetURL.pathExtension
+ var counter = 1
+ repeat {
+ let candidate = "\(baseName)-\(counter).\(ext)"
+ targetURL = directory.appendingPathComponent(candidate)
+ counter += 1
+ } while fileManager.fileExists(atPath: targetURL.path)
+ return targetURL
}
- private func startContinuedProcessing(withTitle title: String) {
- guard ContinuedProcessingManager.shared.isSupported,
- UserDefaults.standard.bool(forKey: UserDefaults.Keys.enableContinuedProcessing) else { return }
- stopProgressTimer()
- reportedProgress = 0.05
- ContinuedProcessingManager.shared.begin(title: title, subtitle: "Script execution in progress")
- ContinuedProcessingManager.shared.updateProgress(reportedProgress)
- let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .background))
- timer.schedule(deadline: .now() + 5, repeating: 5)
- timer.setEventHandler { [weak self] in
- guard let self else { return }
- self.reportedProgress = min(0.9, self.reportedProgress + 0.1)
- ContinuedProcessingManager.shared.updateProgress(self.reportedProgress)
- if self.reportedProgress >= 0.9 {
- self.stopProgressTimer()
+ private func sanitizedScreenshotName(from preferredName: String?) -> String {
+ let defaultName = "screenshot-\(Int(Date().timeIntervalSince1970))"
+ guard var candidate = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !candidate.isEmpty else {
+ return "\(defaultName).png"
+ }
+
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
+ var sanitized = ""
+ sanitized.reserveCapacity(candidate.count)
+ for scalar in candidate.unicodeScalars {
+ if allowed.contains(scalar) {
+ sanitized.append(Character(scalar))
+ } else {
+ sanitized.append("_")
}
}
- timer.resume()
- progressTimer = timer
+ if sanitized.isEmpty {
+ sanitized = defaultName
+ }
+ if !sanitized.lowercased().hasSuffix(".png") {
+ sanitized += ".png"
+ }
+ return sanitized
+ }
+
+ private func describeIdeviceError(_ error: UnsafeMutablePointer) -> String {
+ if let messagePointer = error.pointee.message {
+ return "[\(error.pointee.code)] \(String(cString: messagePointer))"
+ }
+ return "[\(error.pointee.code)] Unknown error"
}
- private func stopContinuedProcessing(success: Bool) {
- stopProgressTimer()
- ContinuedProcessingManager.shared.updateProgress(1.0)
- ContinuedProcessingManager.shared.finish(success: success)
+ private func raiseException(_ message: String) {
+ guard let context else { return }
+ context.exception = JSValue(object: message, in: context)
}
+}
- private func stopProgressTimer() {
- progressTimer?.cancel()
- progressTimer = nil
+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)
}
}
diff --git a/StikJIT/JSSupport/ScriptEditorView.swift b/StikJIT/JSSupport/ScriptEditorView.swift
index 40c9f448..06cd282d 100644
--- a/StikJIT/JSSupport/ScriptEditorView.swift
+++ b/StikJIT/JSSupport/ScriptEditorView.swift
@@ -42,29 +42,24 @@ struct ScriptEditorView: View {
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.codeEditorTheme, editorTheme)
-
- Divider()
-
- HStack(spacing: 12) {
- WideGlassyButton(title: "Cancel", systemImage: "xmark") {
- dismiss()
- }
- WideGlassyButton(title: "Save", systemImage: "checkmark") {
- saveScript()
- dismiss()
- }
- }
- .padding(.horizontal)
- .padding(.vertical, 12)
- .frame(maxWidth: .infinity)
- .background(bottomBarBackground)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
+ .ignoresSafeArea(edges: .bottom)
}
.navigationTitle(scriptURL.lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadScript)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ saveScript()
+ dismiss()
+ }
+ }
+ }
.preferredColorScheme(preferredScheme)
+ .tint(Color.white)
+ .toolbar(.hidden, for: .tabBar)
}
private func loadScript() {
@@ -74,51 +69,4 @@ struct ScriptEditorView: View {
private func saveScript() {
try? scriptContent.write(to: scriptURL, atomically: true, encoding: .utf8)
}
-
- private var bottomBarBackground: some View {
- Rectangle()
- .fill(Color.black.opacity(colorScheme == .dark ? 0.35 : 0.08))
- .overlay(
- Rectangle()
- .fill(Color.white.opacity(0.08))
- .frame(height: 1),
- alignment: .top
- )
- }
-}
-
-// MARK: - Equal-width rounded-rectangle button (centered content)
-private 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.85)
- }
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.vertical, 10)
- .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)
- }
}
diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift
index 49412374..e7c8824e 100644
--- a/StikJIT/JSSupport/ScriptListView.swift
+++ b/StikJIT/JSSupport/ScriptListView.swift
@@ -307,19 +307,44 @@ struct ScriptListView: View {
}
if !exists || !isDir.boolValue {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
- if let bundleURL = Bundle.main.url(forResource: "attachDetach", withExtension: "js") {
- let dest = dir.appendingPathComponent("attachDetach.js")
- if !FileManager.default.fileExists(atPath: dest.path) {
- try FileManager.default.copyItem(at: bundleURL, to: dest)
- }
- }
}
+ try ensureDefaultScripts(in: dir)
} catch {
presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription)
}
return dir
}
+ private func ensureDefaultScripts(in directory: URL) throws {
+ let fm = FileManager.default
+ let bundledScripts: [(resource: String, filename: String)] = [
+ ("attachDetach", "attachDetach.js"),
+ ("maciOS", "maciOS.js"),
+ ("Amethyst", "Amethyst.js"),
+ ("Geode", "Geode.js"),
+ ("MeloNX", "MeloNX.js"),
+ ("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)
+ if !fm.fileExists(atPath: destination.path) {
+ try fm.copyItem(at: bundleURL, to: destination)
+ }
+ }
+ }
+ let screenshotURL = directory.appendingPathComponent("screenshot-demo.js")
+ if !fm.fileExists(atPath: screenshotURL.path) {
+ try screenshotDemoScript.write(to: screenshotURL, atomically: true, encoding: .utf8)
+ }
+ let standaloneURL = directory.appendingPathComponent("screenshot-capture.js")
+ if !fm.fileExists(atPath: standaloneURL.path) {
+ try screenshotCaptureScript.write(to: standaloneURL, atomically: true, encoding: .utf8)
+ }
+ }
+
private func loadScripts() {
let dir = scriptsDirectory()
scripts = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))?
@@ -449,3 +474,56 @@ private struct WideGlassyButton: View {
.buttonStyle(.plain)
}
}
+
+private let screenshotDemoScript = """
+// Screenshot Demo Script
+// Attaches to the target, captures a PNG screenshot, and detaches.
+
+function takeScreenshotDemo() {
+ log("[ScreenshotDemo] Starting demo");
+
+ const pid = get_pid();
+ log(`[ScreenshotDemo] Target PID: ${pid}`);
+
+ const attachResponse = send_command(`vAttach;${pid.toString(16)}`);
+ log(`[ScreenshotDemo] attach_response = ${attachResponse}`);
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const fileName = `screenshot-${timestamp}.png`;
+ const savedPath = take_screenshot(fileName);
+
+ if (savedPath && savedPath.length > 0) {
+ log(`[ScreenshotDemo] Screenshot saved to ${savedPath}`);
+ } else {
+ log("[ScreenshotDemo] Device did not report a saved path.");
+ }
+
+ const detachResponse = send_command("D");
+ log(`[ScreenshotDemo] detach_response = ${detachResponse}`);
+ log("[ScreenshotDemo] Demo complete.");
+}
+
+takeScreenshotDemo();
+"""
+
+private let screenshotCaptureScript = """
+// Screenshot Capture Script
+// Takes a screenshot without sending any debugserver commands.
+
+function captureScreenshot() {
+ log("[ScreenshotCapture] Requesting screenshot without attaching…");
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const fileName = `standalone-${timestamp}.png`;
+ const savedPath = take_screenshot(fileName);
+
+ if (savedPath && savedPath.length > 0) {
+ log(`[ScreenshotCapture] Screenshot saved to ${savedPath}`);
+ } else {
+ log("[ScreenshotCapture] Device did not report a saved path.");
+ }
+
+ log("[ScreenshotCapture] Done.");
+}
+
+captureScreenshot();
+"""
diff --git a/StikJIT/melo.js b/StikJIT/MeloNX.js
similarity index 100%
rename from StikJIT/melo.js
rename to StikJIT/MeloNX.js
diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h
index f0317adc..c4b0c21b 100644
--- a/StikJIT/StikJIT-Bridging-Header.h
+++ b/StikJIT/StikJIT-Bridging-Header.h
@@ -10,3 +10,4 @@
#include "idevice/ideviceinfo.h"
#include "idevice/ls.h"
#include "idevice/profiles.h"
+#include "idevice/something.h"
diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift
index a0fda6c7..4329230b 100644
--- a/StikJIT/StikJITApp.swift
+++ b/StikJIT/StikJITApp.swift
@@ -15,17 +15,9 @@ private func registerAdvancedOptionsDefault() {
let os = ProcessInfo.processInfo.operatingSystemVersion
// Enable advanced options by default on iOS 19/26 and above
let enabled = os.majorVersion >= 19
- let defaults = UserDefaults.standard
- defaults.register(defaults: ["enableAdvancedOptions": enabled])
- defaults.register(defaults: [UserDefaults.Keys.txmOverride: false])
- if defaults.object(forKey: UserDefaults.Keys.enableContinuedProcessing) == nil {
- let legacyPiP = defaults.object(forKey: "enablePiP") as? Bool
- if let legacyPiP {
- defaults.set(legacyPiP, forKey: UserDefaults.Keys.enableContinuedProcessing)
- } else {
- defaults.register(defaults: [UserDefaults.Keys.enableContinuedProcessing: enabled])
- }
- }
+ UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled])
+ UserDefaults.standard.register(defaults: ["enablePiP": enabled])
+ UserDefaults.standard.register(defaults: [UserDefaults.Keys.txmOverride: false])
}
// MARK: - Welcome Sheet
@@ -527,7 +519,6 @@ struct HeartbeatApp: App {
init() {
registerAdvancedOptionsDefault()
- ContinuedProcessingManager.shared.configureIfNeeded()
newVerCheck()
let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:)))!
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))!
@@ -567,7 +558,7 @@ struct HeartbeatApp: App {
}
private func triggerAutoVPNStartIfNeeded() {
- guard autoStartVPN else { return }
+ guard autoStartVPN, DeviceConnectionContext.requiresLoopbackVPN else { return }
let manager = TunnelManager.shared
if manager.tunnelStatus == .disconnected || manager.tunnelStatus == .error {
manager.startVPN()
@@ -701,12 +692,14 @@ class MountingProgress: ObservableObject {
}
private func mount() {
- guard TunnelManager.shared.tunnelStatus == .connected else {
- DispatchQueue.main.async {
- self.coolisMounted = false
- self.mountingThread = nil
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ guard TunnelManager.shared.tunnelStatus == .connected else {
+ DispatchQueue.main.async {
+ self.coolisMounted = false
+ self.mountingThread = nil
+ }
+ return
}
- return
}
let currentlyMounted = isMounted()
@@ -724,6 +717,7 @@ class MountingProgress: ObservableObject {
mountingThread = Thread { [weak self] in
guard let self = self else { return }
let mountResult = mountPersonalDDI(
+ deviceIP: DeviceConnectionContext.targetIPAddress,
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,
@@ -766,7 +760,7 @@ func isPairing() -> Bool {
return true
}
-func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
+func startHeartbeatInBackground(requireVPNConnection: Bool? = nil) {
assert(Thread.isMainThread, "startHeartbeatInBackground must be called on the main thread")
let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
@@ -775,8 +769,9 @@ func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
return
}
+ let shouldRequireVPN = requireVPNConnection ?? DeviceConnectionContext.requiresLoopbackVPN
let vpnConnected = TunnelManager.shared.tunnelStatus == .connected
- if requireVPNConnection && !vpnConnected {
+ if shouldRequireVPN && !vpnConnected {
if !heartbeatStartPending {
print("Heartbeat start deferred until VPN connects")
}
@@ -851,7 +846,8 @@ func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
}
func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
- let host = NWEndpoint.Host("10.7.0.1")
+ let targetIP = DeviceConnectionContext.targetIPAddress
+ let host = NWEndpoint.Host(targetIP)
let port = NWEndpoint.Port(rawValue: 62078)!
let connection = NWConnection(host: host, port: port, using: .tcp)
var timeoutWorkItem: DispatchWorkItem?
@@ -861,7 +857,13 @@ func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
connection?.cancel()
DispatchQueue.main.async {
if timeoutWorkItem?.isCancelled == false {
- callback(false, "[TIMEOUT] The loopback VPN is not connected. Try closing this app, turn it off and back on.")
+ let message: String
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ message = "[TIMEOUT] The loopback VPN is not connected. Try closing this app, turn it off and back on."
+ } else {
+ message = "[TIMEOUT] Could not reach the device at \(targetIP). Make sure it’s online and on the same network."
+ }
+ callback(false, message)
}
}
}
@@ -879,13 +881,19 @@ func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
timeoutWorkItem?.cancel()
connection?.cancel()
DispatchQueue.main.async {
- if error == NWError.posix(.ETIMEDOUT) {
- callback(false, "The loopback VPN is not connected. Try closing the app, turn it off and back on.")
- } else if error == NWError.posix(.ECONNREFUSED) {
- callback(false, "Wifi is not connected. StikJIT won't work on cellular data.")
+ let message: String
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ if error == NWError.posix(.ETIMEDOUT) {
+ message = "The loopback VPN is not connected. Try closing the app, turn it off and back on."
+ } else if error == NWError.posix(.ECONNREFUSED) {
+ message = "Wi-Fi is not connected. StikDebug can't connect over cellular data while in loopback mode."
+ } else {
+ message = "VPN check error: \(error.localizedDescription)"
+ }
} else {
- callback(false, "em proxy check error: \(error.localizedDescription)")
+ message = "Could not reach the device at \(targetIP): \(error.localizedDescription)"
}
+ callback(false, message)
}
default:
break
diff --git a/StikJIT/utmjit.js b/StikJIT/UTM-Dolphin.js
similarity index 100%
rename from StikJIT/utmjit.js
rename to StikJIT/UTM-Dolphin.js
diff --git a/StikJIT/Utilities/ContinuedProcessingManager.swift b/StikJIT/Utilities/ContinuedProcessingManager.swift
deleted file mode 100644
index ac86cac3..00000000
--- a/StikJIT/Utilities/ContinuedProcessingManager.swift
+++ /dev/null
@@ -1,177 +0,0 @@
-//
-// ContinuedProcessingManager.swift
-// StikJIT
-//
-// Created by Codex on 11/20/24.
-//
-
-import Foundation
-import BackgroundTasks
-
-final class ContinuedProcessingManager {
- static let shared = ContinuedProcessingManager()
- private let handler: ContinuedProcessingHandling
-
- private init() {
- if #available(iOS 26.0, *) {
- handler = ModernContinuedProcessingHandler()
- } else {
- handler = NoopContinuedProcessingHandler()
- }
- }
-
- var isSupported: Bool { handler.isSupported }
-
- func configureIfNeeded() {
- handler.configureIfNeeded()
- }
-
- func cancelPendingTasks() {
- handler.cancelPendingTasks()
- }
-
- func begin(title: String, subtitle: String) {
- handler.begin(title: title, subtitle: subtitle)
- }
-
- func updateProgress(_ fraction: Double) {
- handler.updateProgress(fraction)
- }
-
- func finish(success: Bool) {
- handler.finish(success: success)
- }
-}
-
-private protocol ContinuedProcessingHandling: AnyObject {
- var isSupported: Bool { get }
- func configureIfNeeded()
- func cancelPendingTasks()
- func begin(title: String, subtitle: String)
- func updateProgress(_ fraction: Double)
- func finish(success: Bool)
-}
-
-private final class NoopContinuedProcessingHandler: ContinuedProcessingHandling {
- var isSupported: Bool { false }
- func configureIfNeeded() {}
- func cancelPendingTasks() {}
- func begin(title: String, subtitle: String) {}
- func updateProgress(_ fraction: Double) {}
- func finish(success: Bool) {}
-}
-
-@available(iOS 26.0, *)
-private final class ModernContinuedProcessingHandler: ContinuedProcessingHandling {
- private let scheduler = BGTaskScheduler.shared
- private let taskIdentifier: String
- private var didRegister = false
- private var activeTask: BGContinuedProcessingTask?
- private let queue = DispatchQueue(label: "com.stikdebug.continuedProcessing",
- qos: .utility)
- private var pendingMetadata: (title: String, subtitle: String)?
-
- init() {
- let bundleID = Bundle.main.bundleIdentifier ?? "com.stik.sj"
- taskIdentifier = "\(bundleID).continuedProcessingTask.script"
- }
-
- var isSupported: Bool { true }
-
- func configureIfNeeded() {
- guard !didRegister else { return }
- scheduler.register(forTaskWithIdentifier: taskIdentifier, using: nil) { [weak self] task in
- guard let continuedTask = task as? BGContinuedProcessingTask else {
- task.setTaskCompleted(success: false)
- return
- }
- self?.handle(task: continuedTask)
- }
- didRegister = true
- }
-
- func begin(title: String, subtitle: String) {
- guard UserDefaults.standard.bool(forKey: UserDefaults.Keys.enableContinuedProcessing) else { return }
- configureIfNeeded()
- var reserved = false
- queue.sync {
- if activeTask == nil && pendingMetadata == nil {
- pendingMetadata = (title: title, subtitle: subtitle)
- reserved = true
- }
- }
- guard reserved else { return }
- // Clear any stale request that might block new submissions.
- scheduler.cancel(taskRequestWithIdentifier: taskIdentifier)
- let request = BGContinuedProcessingTaskRequest(identifier: taskIdentifier,
- title: title,
- subtitle: subtitle)
- request.strategy = .queue
- do {
- try scheduler.submit(request)
- LogManager.shared.addInfoLog("Requested continued processing: \(title)")
- } catch {
- LogManager.shared.addWarningLog("Unable to request continued processing: \(error.localizedDescription)")
- queue.async { [weak self] in
- self?.pendingMetadata = nil
- }
- }
- }
-
- func cancelPendingTasks() {
- queue.async { [weak self] in
- guard let self else { return }
- if let task = activeTask {
- task.setTaskCompleted(success: false)
- activeTask = nil
- }
- pendingMetadata = nil
- scheduler.cancel(taskRequestWithIdentifier: taskIdentifier)
- }
- }
-
- func updateProgress(_ fraction: Double) {
- queue.async { [weak self] in
- guard let task = self?.activeTask else { return }
- let clamped = max(0.0, min(1.0, fraction))
- task.progress.totalUnitCount = max(task.progress.totalUnitCount, 100)
- task.progress.completedUnitCount = Int64(Double(task.progress.totalUnitCount) * clamped)
- }
- }
-
- func finish(success: Bool) {
- queue.async { [weak self] in
- guard let self else { return }
- if let task = self.activeTask {
- task.progress.completedUnitCount = task.progress.totalUnitCount
- task.setTaskCompleted(success: success)
- self.activeTask = nil
- } else if pendingMetadata != nil {
- scheduler.cancel(taskRequestWithIdentifier: taskIdentifier)
- }
- pendingMetadata = nil
- }
- }
-
- private func handle(task: BGContinuedProcessingTask) {
- queue.async { [weak self] in
- guard let self else { return }
- activeTask = task
- if let metadata = pendingMetadata {
- task.updateTitle(metadata.title, subtitle: metadata.subtitle)
- }
- if task.progress.totalUnitCount == 0 {
- task.progress.totalUnitCount = 100
- }
- task.progress.completedUnitCount = 1
- task.expirationHandler = { [weak self] in
- self?.handleExpiration()
- }
- }
- }
-
- private func handleExpiration() {
- LogManager.shared.addWarningLog("Continued processing expired early")
- finish(success: false)
- }
-}
diff --git a/StikJIT/Utilities/DeviceConnectionContext.swift b/StikJIT/Utilities/DeviceConnectionContext.swift
new file mode 100644
index 00000000..3397e2f0
--- /dev/null
+++ b/StikJIT/Utilities/DeviceConnectionContext.swift
@@ -0,0 +1,25 @@
+//
+// DeviceConnectionContext.swift
+// StikJIT
+//
+// Created by Stephen.
+//
+
+import Foundation
+
+enum DeviceConnectionContext {
+ static var isUsingExternalDevice: Bool {
+ DeviceLibraryStore.shared.isUsingExternalDevice
+ }
+
+ static var requiresLoopbackVPN: Bool {
+ !isUsingExternalDevice
+ }
+
+ static var targetIPAddress: String {
+ if let device = DeviceLibraryStore.shared.activeDevice {
+ return device.ipAddress
+ }
+ return UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.2"
+ }
+}
diff --git a/StikJIT/Utilities/DeviceLibraryStore.swift b/StikJIT/Utilities/DeviceLibraryStore.swift
new file mode 100644
index 00000000..ab87c740
--- /dev/null
+++ b/StikJIT/Utilities/DeviceLibraryStore.swift
@@ -0,0 +1,319 @@
+//
+// 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.2",
+ 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
+ }
+ let destinationURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
+ if fileManager.fileExists(atPath: destinationURL.path) {
+ try? fileManager.removeItem(at: destinationURL)
+ }
+ try fileManager.copyItem(at: storedURL, to: destinationURL)
+ try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destinationURL.path)
+
+ 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
+ }
+}
diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift
index 55e50ff8..234eeeb9 100644
--- a/StikJIT/Utilities/Extensions.swift
+++ b/StikJIT/Utilities/Extensions.swift
@@ -23,7 +23,7 @@ extension UserDefaults {
enum Keys {
/// Forces the app to treat the current device as TXM-capable so scripts always run.
static let txmOverride = "overrideTXMForScripts"
- /// Controls whether BGContinuedProcessingTask should be used to keep scripts alive in the background.
- static let enableContinuedProcessing = "enableContinuedProcessing"
+ /// Tracks whether an external device profile is currently active.
+ static let usingExternalDevice = "UsingExternalDevice"
}
}
diff --git a/StikJIT/Utilities/FeatureFlags.swift b/StikJIT/Utilities/FeatureFlags.swift
index 547b761e..3ceec3b3 100644
--- a/StikJIT/Utilities/FeatureFlags.swift
+++ b/StikJIT/Utilities/FeatureFlags.swift
@@ -3,4 +3,8 @@ import Foundation
enum FeatureFlags {
/// Global toggle for exposing location spoofing UI/logic.
static let isLocationSpoofingEnabled = false
+ /// 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
}
diff --git a/StikJIT/Utilities/TabConfiguration.swift b/StikJIT/Utilities/TabConfiguration.swift
index ca2449fb..38653773 100644
--- a/StikJIT/Utilities/TabConfiguration.swift
+++ b/StikJIT/Utilities/TabConfiguration.swift
@@ -3,11 +3,15 @@ import Foundation
enum TabConfiguration {
static let storageKey = "enabledTabIdentifiers"
static let maxSelectableTabs = 4
- private static let baseAllowedIDs: [String] = ["home", "console", "scripts", "profiles", "processes", "deviceinfo"]
+ private static let coreIDs: [String] = ["home", "console", "scripts", "profiles", "deviceinfo"]
static var allowedIDs: [String] {
- var ids = baseAllowedIDs
- if FeatureFlags.isLocationSpoofingEnabled {
- ids.append("location")
+ var ids = coreIDs
+ if FeatureFlags.showBetaTabs {
+ ids.append("processes")
+ ids.append("devicelibrary")
+ if FeatureFlags.isLocationSpoofingEnabled {
+ ids.append("location")
+ }
}
return ids
}
diff --git a/StikJIT/Utilities/ThemeExpansionManager.swift b/StikJIT/Utilities/ThemeExpansionManager.swift
index 197fce6a..1259653f 100644
--- a/StikJIT/Utilities/ThemeExpansionManager.swift
+++ b/StikJIT/Utilities/ThemeExpansionManager.swift
@@ -87,6 +87,7 @@ final class ThemeExpansionManager: ObservableObject {
@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
@@ -96,15 +97,17 @@ final class ThemeExpansionManager: ObservableObject {
private var updatesTask: Task?
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
+ self.hasThemeExpansion = previewUnlocked || isForcedUnlocked
loadCustomThemes()
- if previewUnlocked && customThemes.isEmpty {
+ if (previewUnlocked || isForcedUnlocked) && customThemes.isEmpty {
customThemes = [
CustomTheme(name: "Vapor Trail",
style: .animatedGradient,
@@ -113,7 +116,7 @@ final class ThemeExpansionManager: ObservableObject {
]
}
- guard !previewUnlocked else { return }
+ guard !(previewUnlocked || isForcedUnlocked) else { return }
// Only wire StoreKit listeners if this is an App Store build
if isAppStoreBuild {
@@ -141,6 +144,12 @@ final class ThemeExpansionManager: ObservableObject {
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
@@ -171,6 +180,10 @@ final class ThemeExpansionManager: ObservableObject {
func restorePurchases() async {
guard !isPreviewInstance else { return }
+ guard !isForcedUnlocked else {
+ lastError = nil
+ return
+ }
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
@@ -188,6 +201,11 @@ final class ThemeExpansionManager: ObservableObject {
func purchaseThemeExpansion() async {
guard !isPreviewInstance else { return }
+ guard !isForcedUnlocked else {
+ lastError = nil
+ hasThemeExpansion = true
+ return
+ }
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
diff --git a/StikJIT/Utilities/mountDDI.swift b/StikJIT/Utilities/mountDDI.swift
index 17a76d5d..f488347b 100644
--- a/StikJIT/Utilities/mountDDI.swift
+++ b/StikJIT/Utilities/mountDDI.swift
@@ -47,8 +47,10 @@ func htons(_ value: UInt16) -> UInt16 {
}
func isMounted() -> Bool {
- guard TunnelManager.shared.tunnelStatus == .connected else {
- return false
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ guard TunnelManager.shared.tunnelStatus == .connected else {
+ return false
+ }
}
var addr = sockaddr_in()
@@ -59,7 +61,7 @@ func isMounted() -> Bool {
let pairingFilePath = URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path
- guard inet_pton(AF_INET, "10.7.0.1", &addr.sin_addr) == 1 else {
+ guard inet_pton(AF_INET, DeviceConnectionContext.targetIPAddress, &addr.sin_addr) == 1 else {
print("Invalid IP address")
return false
}
diff --git a/StikJIT/Views/DeviceLibraryView.swift b/StikJIT/Views/DeviceLibraryView.swift
new file mode 100644
index 00000000..8a5f27cc
--- /dev/null
+++ b/StikJIT/Views/DeviceLibraryView.swift
@@ -0,0 +1,535 @@
+//
+// 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(requireVPNConnection: false)
+ 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(requireVPNConnection: false)
+ let title = activatingDefault ? "Loopback Active" : "Device Activated"
+ let message = activatingDefault
+ ? "Switched back to debugging this device over the loopback VPN."
+ : "\(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(@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
+ }
+ }
+}
diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift
index 370a4394..c6dc11da 100644
--- a/StikJIT/Views/DisplayView.swift
+++ b/StikJIT/Views/DisplayView.swift
@@ -104,6 +104,13 @@ struct DisplayView: View {
themeExpansion?.customTheme(for: selectedThemeIdentifier)
}
+ private var selectedThemeName: String {
+ if let custom = selectedCustomTheme {
+ return custom.name
+ }
+ return selectedBuiltInTheme?.displayName ?? "Theme"
+ }
+
private var backgroundStyle: BackgroundStyle {
themeExpansion?.backgroundStyle(for: selectedThemeIdentifier) ?? AppTheme.system.backgroundStyle
}
@@ -225,11 +232,12 @@ struct DisplayView: View {
// MARK: - Cards
private var usernameCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text("Username")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Username")
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
HStack {
TextField("Username", text: $username)
@@ -254,193 +262,166 @@ struct DisplayView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
+ }
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
private var accentCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text("Accent")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- AccentColorPicker(selectedColor: $selectedAccentColor)
-
- HStack(spacing: 12) {
- Button {
- if let hex = selectedAccentColor.toHex() {
- customAccentColorHex = hex
- } else {
- customAccentColorHex = ""
- }
- showSavedToast()
- } label: {
- HStack {
- Image(systemName: "checkmark.circle.fill")
- Text("Save")
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(selectedAccentColor)
- )
- .foregroundColor(selectedAccentColor.contrastText())
- }
-
- Button {
- customAccentColorHex = ""
- selectedAccentColor = .blue
- showSavedToast()
- } label: {
- HStack {
- Image(systemName: "arrow.uturn.backward.circle")
- Text("Reset")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(UIColor.tertiarySystemBackground))
- )
- }
- }
- }
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
- }
-
- private var themeExpansionUpsellCard: some View {
- let isAppStore = themeExpansion?.isAppStoreBuild ?? true
- let productLoaded = themeExpansion?.themeExpansionProduct != nil
- return VStack(alignment: .leading, spacing: 14) {
- Text("StikDebug Theme Expansion")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- if !isAppStore {
- Text("Theme Expansion is coming soon on this store.")
- .font(.body)
- .foregroundColor(.secondary)
- Text("For now, you can continue using the default theme.")
- .font(.footnote)
- .foregroundColor(.secondary)
- } else {
- Text("Unlock custom accent colors and dynamic backgrounds with the Theme Expansion.")
- .font(.body)
- .foregroundColor(.secondary)
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Accent")
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
- if let price = themeExpansion?.themeExpansionProduct?.displayPrice {
- Text("One-time purchase • \(price)")
- .font(.subheadline)
- .foregroundColor(.secondary)
- }
+ AccentColorPicker(selectedColor: $selectedAccentColor)
- if productLoaded, let manager = themeExpansion {
+ HStack(spacing: 12) {
Button {
- Task { await manager.purchaseThemeExpansion() }
+ if let hex = selectedAccentColor.toHex() {
+ customAccentColorHex = hex
+ } else {
+ customAccentColorHex = ""
+ }
+ showSavedToast()
} label: {
HStack {
- if manager.isProcessing {
- ProgressView()
- .progressViewStyle(.circular)
- }
- Text(manager.isProcessing ? "Purchasing…" : "Unlock Theme Expansion")
+ Image(systemName: "checkmark.circle.fill")
+ Text("Save")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color.blue)
+ .fill(selectedAccentColor)
)
- .foregroundColor(Color.blue.contrastText())
+ .foregroundColor(selectedAccentColor.contrastText())
}
- .disabled(manager.isProcessing)
- } else if let manager = themeExpansion {
+
Button {
- Task { await manager.refreshEntitlements() }
+ customAccentColorHex = ""
+ selectedAccentColor = .blue
+ showSavedToast()
} label: {
HStack {
- if manager.isProcessing {
- ProgressView()
- .progressViewStyle(.circular)
- }
- Text(manager.isProcessing ? "Contacting App Store…" : "Try Again")
- .fontWeight(.semibold)
+ Image(systemName: "arrow.uturn.backward.circle")
+ Text("Reset")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.blue.opacity(0.4), lineWidth: 1)
+ .fill(Color(UIColor.tertiarySystemBackground))
)
}
- .disabled(manager.isProcessing)
}
+ }
+ }
+ }
- if let manager = themeExpansion {
- Button {
- Task { await manager.restorePurchases() }
- } label: {
- Text("Restore Purchase")
+ private var themeExpansionUpsellCard: some View {
+ let isAppStore = themeExpansion?.isAppStoreBuild ?? true
+ let productLoaded = themeExpansion?.themeExpansionProduct != nil
+ return appGlassCard {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("StikDebug Theme Expansion")
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+
+ if !isAppStore {
+ Text("Theme Expansion is coming soon on this store.")
+ .font(.body)
+ .foregroundColor(.secondary)
+ Text("For now, you can continue using the default theme.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ } else {
+ Text("Unlock custom accent colors and dynamic backgrounds with the Theme Expansion.")
+ .font(.body)
+ .foregroundColor(.secondary)
+
+ if let price = themeExpansion?.themeExpansionProduct?.displayPrice {
+ Text("One-time purchase • \(price)")
.font(.subheadline)
- .fontWeight(.semibold)
+ .foregroundColor(.secondary)
+ }
+
+ if productLoaded, let manager = themeExpansion {
+ Button {
+ Task { await manager.purchaseThemeExpansion() }
+ } label: {
+ HStack {
+ if manager.isProcessing {
+ ProgressView()
+ .progressViewStyle(.circular)
+ }
+ Text(manager.isProcessing ? "Purchasing…" : "Unlock Theme Expansion")
+ .fontWeight(.semibold)
+ }
.frame(maxWidth: .infinity)
- .padding(.vertical, 10)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color.blue)
+ )
+ .foregroundColor(Color.blue.contrastText())
+ }
+ .disabled(manager.isProcessing)
+ } else if let manager = themeExpansion {
+ Button {
+ Task { await manager.refreshEntitlements() }
+ } label: {
+ HStack {
+ if manager.isProcessing {
+ ProgressView()
+ .progressViewStyle(.circular)
+ }
+ Text(manager.isProcessing ? "Contacting App Store…" : "Try Again")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.blue.opacity(0.4), lineWidth: 1)
)
+ }
+ .disabled(manager.isProcessing)
}
- .disabled(manager.isProcessing)
- }
- if let manager = themeExpansion, !productLoaded, manager.lastError == nil {
- Text(manager.isProcessing ? "Contacting the App Store…" : "Waiting for App Store information.")
- .font(.footnote)
- .foregroundColor(.secondary)
- }
+ if let manager = themeExpansion {
+ Button {
+ Task { await manager.restorePurchases() }
+ } label: {
+ Text("Restore Purchase")
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.blue.opacity(0.4), lineWidth: 1)
+ )
+ }
+ .disabled(manager.isProcessing)
+ }
- if let error = themeExpansion?.lastError {
- Text(error)
- .font(.footnote)
- .foregroundColor(.red)
+ if let manager = themeExpansion, !productLoaded, manager.lastError == nil {
+ Text(manager.isProcessing ? "Contacting the App Store…" : "Waiting for App Store information.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+
+ if let error = themeExpansion?.lastError {
+ Text(error)
+ .font(.footnote)
+ .foregroundColor(.red)
+ }
}
}
}
- .padding(20)
- .frame(maxWidth: .infinity) // ensure background fills available width
- .background(
- // Slightly thicker material so the upsell stands out over the previews
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.regularMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
.task {
if let manager = themeExpansion,
manager.isAppStoreBuild,
@@ -453,157 +434,142 @@ struct DisplayView: View {
}
private var jitOptionsCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text("App List")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- VStack(alignment: .leading, spacing: 6) {
- Toggle("Load App Icons", isOn: $loadAppIconsOnJIT)
- .tint(accentColor)
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("App List")
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
- Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.")
- .font(.footnote)
- .foregroundColor(.secondary)
+ VStack(alignment: .leading, spacing: 6) {
+ Toggle("Load App Icons", isOn: $loadAppIconsOnJIT)
+ .tint(accentColor)
+
+ Text("Disabling this will hide app icons in the app list and may improve performance, while also giving it a more minimalistic look.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
}
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
private var themeCard: some View {
- VStack(alignment: .leading, spacing: 14) {
- Text("Theme")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- // Grid of theme previews
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
- ForEach(AppTheme.allCases, id: \.self) { theme in
- let isSelected = selectedBuiltInTheme == theme && selectedCustomTheme == nil
- ThemePreviewCard(style: theme.backgroundStyle,
- title: theme.displayName,
- selected: isSelected,
- action: {
- guard hasThemeExpansion else { return }
- appThemeRaw = theme.rawValue
- applyThemePreferences()
- showSavedToast()
- },
- staticPreview: !isSelected)
- }
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Themes")
+ .font(.title3.weight(.semibold))
+ .foregroundColor(.primary)
+
+ selectedThemePreview
+ Divider()
+ builtInThemesGrid(interactive: hasThemeExpansion, locked: !hasThemeExpansion)
}
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
- // Static version used in locked preview to avoid background animation cost
- private var themeCardStatic: some View {
- VStack(alignment: .leading, spacing: 14) {
- Text("Theme")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
- ForEach(AppTheme.allCases, id: \.self) { theme in
- ThemePreviewCard(style: theme.backgroundStyle,
- title: theme.displayName,
- selected: selectedBuiltInTheme == theme && selectedCustomTheme == nil,
- action: {},
- staticPreview: true)
- }
+ private var selectedThemePreview: some View {
+ ThemePreviewCard(style: backgroundStyle,
+ title: selectedThemeName,
+ selected: true,
+ action: {},
+ staticPreview: false,
+ allowsInteraction: false,
+ height: 160)
+ .accessibilityHidden(true)
+ }
+
+ private var themeLockedPreviewCard: some View {
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Themes")
+ .font(.title3.weight(.semibold))
+ .foregroundColor(.primary)
+ builtInThemesGrid(interactive: false, locked: true)
}
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
+ private var gridColumns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 10), count: 2)
+ }
+
+ @ViewBuilder
+ private func builtInThemesGrid(interactive: Bool, locked: Bool) -> some View {
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ForEach(AppTheme.allCases, id: \.self) { theme in
+ let isSelected = selectedBuiltInTheme == theme && selectedCustomTheme == nil
+ ThemeOptionTile(style: theme.backgroundStyle,
+ title: theme.displayName,
+ isSelected: isSelected,
+ isLocked: locked,
+ interactive: interactive) {
+ guard hasThemeExpansion else { return }
+ appThemeRaw = theme.rawValue
+ applyThemePreferences()
+ showSavedToast()
+ }
+ }
+ }
+ }
@ViewBuilder
private var customThemesSection: some View {
if hasThemeExpansion, let manager = themeExpansion {
- VStack(alignment: .leading, spacing: 14) {
- HStack {
- Text("Custom Themes")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
- Spacer()
- Button {
- showingCreateCustomTheme = true
- } label: {
- Label("New", systemImage: "plus.circle.fill")
- .font(.subheadline.weight(.semibold))
- }
- }
-
- if manager.customThemes.isEmpty {
- VStack(spacing: 8) {
- Text("Create your own themes with custom colors and motion.")
- .font(.body)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.leading)
- Button(action: { showingCreateCustomTheme = true }) {
- Text("Create a Custom Theme")
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("Custom Themes")
+ .font(.title3.weight(.semibold))
+ .foregroundColor(.primary)
+ Spacer()
+ Button {
+ showingCreateCustomTheme = true
+ } label: {
+ Label("New", systemImage: "plus.circle.fill")
.font(.subheadline.weight(.semibold))
- .padding(.vertical, 10)
- .frame(maxWidth: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color.blue)
- )
- .foregroundColor(Color.blue.contrastText())
}
}
- } else {
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
- ForEach(manager.customThemes, id: \.id) { theme in
- let identifier = manager.customThemeIdentifier(for: theme)
- let isSelected = selectedCustomTheme?.id == theme.id
- ThemePreviewCard(style: manager.backgroundStyle(for: identifier),
- title: theme.name,
- selected: isSelected,
- action: {
- appThemeRaw = identifier
- applyThemePreferences()
- showSavedToast()
- },
- staticPreview: !isSelected)
- .contextMenu {
- Button("Edit") { editingCustomTheme = theme }
- Button("Delete", role: .destructive) {
- manager.delete(customTheme: theme)
- let id = manager.customThemeIdentifier(for: theme)
- if appThemeRaw == id {
- appThemeRaw = AppTheme.system.rawValue
- applyThemePreferences()
+
+ if manager.customThemes.isEmpty {
+ VStack(spacing: 8) {
+ Text("Create your own themes with custom colors and motion.")
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.leading)
+ Button(action: { showingCreateCustomTheme = true }) {
+ Text("Create a Custom Theme")
+ .font(.subheadline.weight(.semibold))
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color.blue)
+ )
+ .foregroundColor(Color.blue.contrastText())
+ }
+ }
+ } else {
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ForEach(manager.customThemes, id: \.id) { theme in
+ let identifier = manager.customThemeIdentifier(for: theme)
+ let isSelected = selectedCustomTheme?.id == theme.id
+ ThemeOptionTile(style: manager.backgroundStyle(for: identifier),
+ title: theme.name,
+ isSelected: isSelected,
+ isLocked: false,
+ interactive: true) {
+ appThemeRaw = identifier
+ applyThemePreferences()
+ showSavedToast()
+ }
+ .contextMenu {
+ Button("Edit") { editingCustomTheme = theme }
+ Button("Delete", role: .destructive) {
+ manager.delete(customTheme: theme)
+ let id = manager.customThemeIdentifier(for: theme)
+ if appThemeRaw == id {
+ appThemeRaw = AppTheme.system.rawValue
+ applyThemePreferences()
+ }
}
}
}
@@ -611,16 +577,6 @@ struct DisplayView: View {
}
}
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
- )
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
}
}
@@ -685,68 +641,131 @@ struct DisplayView: View {
// Use static theme grid inside the locked preview to avoid animation cost
private var accentPreview: some View { lockedPreview(accentCard) }
- private var themePreview: some View { lockedPreview(themeCardStatic) }
+ private var themePreview: some View { lockedPreview(themeLockedPreviewCard) }
private var customThemesPreview: some View {
lockedPreview(customThemesPreviewCard)
}
private var customThemesPreviewCard: some View {
- VStack(alignment: .leading, spacing: 14) {
- HStack {
- Text("Custom Themes")
- .font(.title3)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
- Spacer()
- Label("New", systemImage: "plus.circle.fill")
- .font(.subheadline.weight(.semibold))
- .opacity(0.6)
- }
-
- LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
- ThemePreviewCard(
- style: .customGradient(colors: [Color(hex: "#3E4C7C") ?? .indigo,
- Color(hex: "#1C1F3A") ?? .blue]),
- title: "Midnight Fade",
- selected: false,
- action: {},
- staticPreview: true
- )
- ThemePreviewCard(
- style: .customGradient(colors: [Color(hex: "#00F5A0") ?? .green,
- Color(hex: "#00D9F5") ?? .cyan,
- Color(hex: "#C96BFF") ?? .purple]),
- title: "Neon Drift",
- selected: false,
- action: {},
- staticPreview: true
- )
+ appGlassCard {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Text("Custom Themes")
+ .font(.title3.weight(.semibold))
+ .foregroundColor(.primary)
+ Spacer()
+ Label("New", systemImage: "plus.circle.fill")
+ .font(.subheadline.weight(.semibold))
+ .opacity(0.6)
+ }
+
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#3E4C7C") ?? .indigo,
+ Color(hex: "#1C1F3A") ?? .blue]),
+ title: "Midnight Fade",
+ isSelected: false,
+ isLocked: true,
+ interactive: false,
+ action: {})
+
+ ThemeOptionTile(style: .customGradient(colors: [Color(hex: "#00F5A0") ?? .green,
+ Color(hex: "#00D9F5") ?? .cyan,
+ Color(hex: "#C96BFF") ?? .purple]),
+ title: "Neon Drift",
+ isSelected: false,
+ isLocked: true,
+ interactive: false,
+ action: {})
+ }
}
}
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
+ }
+}
+
+// MARK: - Theme Option Tile & Preview Card
+
+private struct ThemeOptionTile: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ let style: BackgroundStyle
+ let title: String
+ let isSelected: Bool
+ let isLocked: Bool
+ let interactive: Bool
+ let action: () -> Void
+
+ private var borderColor: Color {
+ if isSelected { return .accentColor }
+ if isLocked { return Color.black.opacity(0.08) }
+ return Color.black.opacity(0.12)
+ }
+
+ var body: some View {
+ let tile = ZStack(alignment: .bottomLeading) {
+ ThemePreviewThumbnail(style: style,
+ colorScheme: colorScheme)
.overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.black.opacity(0.12))
)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .foregroundColor(.white)
+ if isLocked {
+ Text("Locked")
+ .font(.caption2.weight(.semibold))
+ .foregroundColor(.white.opacity(0.8))
+ }
+ }
+ Spacer()
+ if isLocked {
+ Image(systemName: "lock.fill")
+ .foregroundColor(.white.opacity(0.85))
+ .font(.caption.weight(.bold))
+ } else if isSelected {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.white)
+ .font(.title3.weight(.bold))
+ }
+ }
+ .padding(12)
+ }
+ .frame(height: 110)
+ .frame(maxWidth: .infinity)
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1)
)
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
+ .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
+ .opacity(isLocked ? 0.85 : 1)
+
+ if interactive && !isLocked {
+ Button(action: action) {
+ tile
+ }
+ .buttonStyle(.plain)
+ } else {
+ tile
+ }
}
}
-// MARK: - Theme Preview Card
-
private struct ThemePreviewCard: View {
let style: BackgroundStyle
let title: String
let selected: Bool
let action: () -> Void
var staticPreview: Bool = false
+ var allowsInteraction: Bool = true
+ var height: CGFloat = 120
@Environment(\.colorScheme) private var colorScheme
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
private func staticized(_ style: BackgroundStyle) -> BackgroundStyle {
switch style {
@@ -769,35 +788,341 @@ private struct ThemePreviewCard: View {
}
var body: some View {
- Button(action: action) {
- ZStack {
- ThemedBackground(style: staticPreview ? staticized(style) : style)
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(.ultraThinMaterial)
- .padding(6)
- .opacity(0.55)
- )
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
-
- VStack(spacing: 6) {
- Text(title)
- .font(.footnote.weight(.semibold))
- .foregroundColor(.primary)
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
- .background(.ultraThinMaterial, in: Capsule())
+ Group {
+ if allowsInteraction {
+ Button(action: action) {
+ cardBody
}
- .padding(8)
+ .buttonStyle(.plain)
+ } else {
+ cardBody
}
- .frame(height: 120)
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1)
+ }
+ }
+
+ private var cardBody: some View {
+ ZStack {
+ backgroundContent
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(.ultraThinMaterial)
+ .padding(6)
+ .opacity(0.55)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+
+ VStack(spacing: 6) {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .foregroundColor(.primary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(.ultraThinMaterial, in: Capsule())
+ }
+ .padding(8)
+ }
+ .frame(height: height)
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1)
+ )
+ .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
+ }
+
+ private var backgroundContent: some View {
+ Group {
+ if staticPreview {
+ ThemePreviewThumbnail(style: staticized(style),
+ colorScheme: colorScheme)
+ } else {
+ UIKitThemeBackground(style: style,
+ reduceMotion: reduceMotion,
+ colorScheme: colorScheme)
+ }
+ }
+ }
+}
+
+// MARK: - UIKit-powered background previews
+
+private struct UIKitThemeBackground: UIViewRepresentable {
+ let style: BackgroundStyle
+ let reduceMotion: Bool
+ let colorScheme: ColorScheme
+
+ func makeUIView(context: Context) -> ThemePreviewUIKitView {
+ ThemePreviewUIKitView()
+ }
+
+ func updateUIView(_ uiView: ThemePreviewUIKitView, context: Context) {
+ uiView.configure(style: style,
+ reduceMotion: reduceMotion,
+ interfaceStyle: colorScheme)
+ }
+}
+
+private final class ThemePreviewUIKitView: UIView {
+ private let gradientLayer = CAGradientLayer()
+ private var emitterLayer: CAEmitterLayer?
+ private var currentConfigurationKey: String?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ clipsToBounds = true
+ layer.cornerCurve = .continuous
+ layer.cornerRadius = 16
+ gradientLayer.startPoint = CGPoint(x: 0, y: 0)
+ gradientLayer.endPoint = CGPoint(x: 1, y: 1)
+ layer.addSublayer(gradientLayer)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ gradientLayer.frame = bounds
+ emitterLayer?.emitterPosition = CGPoint(x: bounds.midX, y: bounds.midY)
+ emitterLayer?.emitterSize = bounds.size
+ }
+
+ func configure(style: BackgroundStyle, reduceMotion: Bool, interfaceStyle: ColorScheme) {
+ let key = configurationKey(for: style,
+ reduceMotion: reduceMotion,
+ interfaceStyle: interfaceStyle)
+ guard key != currentConfigurationKey else { return }
+ currentConfigurationKey = key
+ gradientLayer.removeAllAnimations()
+ emitterLayer?.removeFromSuperlayer()
+ emitterLayer = nil
+
+ switch style {
+ case .staticGradient(let colors):
+ applyGradient(colors: colors)
+ case .animatedGradient(let colors, let speed):
+ applyAnimatedGradient(colors: colors, speed: speed, reduceMotion: reduceMotion)
+ case .blobs(_, let background):
+ // UIKit snapshot of blobs can be heavy; fall back to background gradient for previews.
+ applyGradient(colors: background)
+ case .particles(let particle, let background):
+ applyGradient(colors: background)
+ applyParticleOverlay(color: particle, reduceMotion: reduceMotion)
+ case .customGradient(let colors):
+ applyGradient(colors: colors)
+ case .adaptiveGradient(let light, let dark):
+ let palette = interfaceStyle == .dark ? dark : light
+ applyGradient(colors: palette)
+ }
+ }
+
+ private func applyGradient(colors: [Color]) {
+ gradientLayer.colors = colors.nonEmptyOrFallback().map { UIColor($0).cgColor }
+ }
+
+ private func applyAnimatedGradient(colors: [Color], speed: Double, reduceMotion: Bool) {
+ applyGradient(colors: colors)
+ guard !reduceMotion else { return }
+
+ let duration = max(8.0, 18.0 / max(speed, 0.02))
+ let startAnimation = CABasicAnimation(keyPath: "startPoint")
+ startAnimation.fromValue = CGPoint(x: 0, y: 0)
+ startAnimation.toValue = CGPoint(x: 1, y: 1)
+ startAnimation.duration = duration
+ startAnimation.autoreverses = true
+ startAnimation.repeatCount = .infinity
+ startAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+
+ let endAnimation = CABasicAnimation(keyPath: "endPoint")
+ endAnimation.fromValue = CGPoint(x: 1, y: 1)
+ endAnimation.toValue = CGPoint(x: 0, y: 0)
+ endAnimation.duration = duration
+ endAnimation.autoreverses = true
+ endAnimation.repeatCount = .infinity
+ endAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+
+ gradientLayer.add(startAnimation, forKey: "startPoint")
+ gradientLayer.add(endAnimation, forKey: "endPoint")
+ }
+
+ private func applyParticleOverlay(color: Color, reduceMotion: Bool) {
+ guard !reduceMotion else { return }
+ let emitter = CAEmitterLayer()
+ emitter.emitterShape = .rectangle
+ emitter.emitterMode = .surface
+ emitter.renderMode = .additive
+ emitter.emitterCells = [makeParticleCell(color: color)]
+ layer.addSublayer(emitter)
+ emitterLayer = emitter
+ setNeedsLayout()
+ }
+
+ private func makeParticleCell(color: Color) -> CAEmitterCell {
+ let cell = CAEmitterCell()
+ cell.birthRate = 25
+ cell.lifetime = 18
+ cell.velocity = 12
+ cell.velocityRange = 8
+ cell.scale = 0.015
+ cell.scaleRange = 0.01
+ cell.alphaSpeed = -0.02
+ cell.contents = particleImage(color: color).cgImage
+ return cell
+ }
+
+ private func particleImage(color: Color) -> UIImage {
+ let size: CGFloat = 6
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
+ return renderer.image { ctx in
+ let rect = CGRect(x: 0, y: 0, width: size, height: size)
+ ctx.cgContext.setFillColor(UIColor(color).withAlphaComponent(0.9).cgColor)
+ ctx.cgContext.fillEllipse(in: rect)
+ }
+ }
+
+ private func configurationKey(for style: BackgroundStyle,
+ reduceMotion: Bool,
+ interfaceStyle: ColorScheme) -> String {
+ let schemeKey = interfaceStyle == .dark ? "dark" : "light"
+ return "\(style.previewIdentityKey(for: interfaceStyle))|motion:\(reduceMotion)|scheme:\(schemeKey)"
+ }
+}
+
+private extension Array where Element == Color {
+ func nonEmptyOrFallback() -> [Color] {
+ if isEmpty { return [Color.blue, Color.purple] }
+ if count == 1 { return [self[0], self[0].opacity(0.7)] }
+ return self
+ }
+
+ func previewIdentityKey() -> String {
+ map { $0.previewIdentityKey }.joined(separator: ",")
+ }
+}
+
+private extension Color {
+ var previewIdentityKey: String {
+ if let hex = toHex() {
+ return hex
+ }
+ return String(describing: self)
+ }
+}
+
+private extension BackgroundStyle {
+ func previewIdentityKey(for scheme: ColorScheme) -> String {
+ switch self {
+ case .staticGradient(let colors):
+ return "static:\(colors.previewIdentityKey())"
+ case .animatedGradient(let colors, let speed):
+ return "animated:\(String(format: "%.4f", speed)):\(colors.previewIdentityKey())"
+ case .blobs(let colors, let background):
+ return "blobs:\(colors.previewIdentityKey())|bg:\(background.previewIdentityKey())"
+ case .particles(let particle, let background):
+ return "particles:\(particle.previewIdentityKey)|bg:\(background.previewIdentityKey())"
+ case .customGradient(let colors):
+ return "custom:\(colors.previewIdentityKey())"
+ case .adaptiveGradient(let light, let dark):
+ let palette = scheme == .dark ? dark : light
+ return "adaptive:\(palette.previewIdentityKey())"
+ }
+ }
+
+ func thumbnailColors(for scheme: ColorScheme) -> [Color] {
+ switch self {
+ case .staticGradient(let colors):
+ return colors
+ case .animatedGradient(let colors, _):
+ return colors
+ case .blobs(_, let background):
+ return background
+ case .particles(_, let background):
+ return background
+ case .customGradient(let colors):
+ return colors
+ case .adaptiveGradient(let light, let dark):
+ return scheme == .dark ? dark : light
+ }
+ }
+}
+
+private struct ThemePreviewThumbnail: View {
+ let style: BackgroundStyle
+ let colorScheme: ColorScheme
+ var cornerRadius: CGFloat = 16
+ @State private var image: UIImage?
+
+ private var cacheKey: String {
+ style.previewIdentityKey(for: colorScheme)
+ }
+
+ var body: some View {
+ ZStack {
+ if let image {
+ Image(uiImage: image)
+ .resizable()
+ .scaledToFill()
+ } else {
+ placeholderGradient
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+ .task(id: cacheKey) {
+ image = await ThemePreviewThumbnailCache.shared.image(for: style,
+ scheme: colorScheme)
+ }
+ }
+
+ private var placeholderGradient: some View {
+ LinearGradient(colors: style.thumbnailColors(for: colorScheme).nonEmptyOrFallback(),
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing)
+ }
+}
+
+private final class ThemePreviewThumbnailCache {
+ static let shared = ThemePreviewThumbnailCache()
+ private let cache = NSCache()
+ private let queue = DispatchQueue(label: "ThemePreviewThumbnailCache",
+ qos: .userInitiated)
+ private let renderSize = CGSize(width: 320, height: 200)
+
+ func image(for style: BackgroundStyle, scheme: ColorScheme) async -> UIImage {
+ let key = style.previewIdentityKey(for: scheme) as NSString
+ if let cached = cache.object(forKey: key) {
+ return cached
+ }
+
+ return await withCheckedContinuation { continuation in
+ queue.async {
+ let image = self.drawThumbnail(style: style, scheme: scheme)
+ self.cache.setObject(image, forKey: key)
+ continuation.resume(returning: image)
+ }
+ }
+ }
+
+ private func drawThumbnail(style: BackgroundStyle, scheme: ColorScheme) -> UIImage {
+ let colors = style.thumbnailColors(for: scheme).nonEmptyOrFallback()
+ let uiColors = colors.map { UIColor($0) }
+ let renderer = UIGraphicsImageRenderer(size: renderSize)
+ return renderer.image { ctx in
+ guard let gradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: uiColors.map { $0.cgColor } as CFArray,
+ locations: nil
+ ) else {
+ ctx.cgContext.setFillColor(uiColors.first?.cgColor ?? UIColor.systemBackground.cgColor)
+ ctx.cgContext.fill(CGRect(origin: .zero, size: renderSize))
+ return
+ }
+ ctx.cgContext.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: 0, y: 0),
+ end: CGPoint(x: renderSize.width, y: renderSize.height),
+ options: []
)
- .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
}
- .buttonStyle(.plain)
}
}
diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift
index d7d7adc9..020d0af0 100644
--- a/StikJIT/Views/HomeView.swift
+++ b/StikJIT/Views/HomeView.swift
@@ -7,6 +7,7 @@
import SwiftUI
import UniformTypeIdentifiers
+import Pipify
import UIKit
import WidgetKit
import Combine
@@ -20,7 +21,7 @@ struct JITEnableConfiguration {
}
struct HomeView: View {
-
+
@AppStorage("username") private var username = "User"
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.colorScheme) private var colorScheme
@@ -48,14 +49,16 @@ struct HomeView: View {
@State private var viewDidAppeared = false
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
-
- @AppStorage("useDefaultScript") private var useDefaultScript = false
+
+ @AppStorage("enablePiP") private var enablePiP = true
@State var scriptViewShow = false
+ @State private var pipRequired = false
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
@State var jsModel: RunJSViewModel?
@StateObject private var tunnel = TunnelManager.shared
@ObservedObject private var mounting = MountingProgress.shared
+ @ObservedObject private var deviceStore = DeviceLibraryStore.shared
@State private var heartbeatOK = false
@State private var cachedAppNames: [String: String] = [:]
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
@@ -73,18 +76,19 @@ struct HomeView: View {
@State private var isSchedulingInitialSetup = false
@AppStorage("cachedAppNamesData") private var cachedAppNamesData: Data?
@AppStorage("autoStartVPN") private var autoStartVPN = true
-
+
@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 ddiMounted: Bool { mounting.coolisMounted }
private var canConnectByApp: Bool { pairingFileExists && ddiMounted }
+ private var requiresLoopbackVPN: Bool { !deviceStore.isUsingExternalDevice }
private var pairingFileLikelyInvalid: Bool {
(pairingFileExists || pairingFilePresentOnDisk) &&
!isValidatingPairingFile &&
@@ -120,9 +124,9 @@ struct HomeView: View {
private var shouldPromptForWiFi: Bool {
pairingFileLikelyInvalid && !wifiConnected && isCellularActive
}
-
+
private let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
-
+
var body: some View {
NavigationStack {
ZStack {
@@ -134,9 +138,9 @@ struct HomeView: View {
welcomeCard
setupCard
connectCard
- // if pairingFileExists {
- // quickConnectCard
- // }
+ // if pairingFileExists {
+ // quickConnectCard
+ // }
if !pinnedLaunchItems.isEmpty {
launchShortcutsCard
}
@@ -146,7 +150,7 @@ struct HomeView: View {
.padding(.vertical, 30)
}
.scrollIndicators(.hidden)
-
+
if isImportingFile {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Processing pairing file…")
@@ -161,7 +165,7 @@ struct HomeView: View {
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
-
+
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
toast("✓ Pairing file successfully imported")
}
@@ -179,7 +183,7 @@ struct HomeView: View {
scheduleInitialSetupWork()
startWiFiMonitoring()
startCellularMonitoring()
- if autoStartVPN && tunnel.tunnelStatus == .disconnected {
+ if requiresLoopbackVPN && autoStartVPN && tunnel.tunnelStatus == .disconnected {
TunnelManager.shared.startVPN()
}
if !hasAutoStartedConnectionCheck {
@@ -213,6 +217,7 @@ struct HomeView: View {
}
}
.onChange(of: tunnel.tunnelStatus) { _, newStatus in
+ guard requiresLoopbackVPN else { return }
if newStatus == .connected {
loadAppListIfNeeded(force: cachedAppNames.isEmpty)
runConnectionDiagnostics()
@@ -246,7 +251,9 @@ struct HomeView: View {
pairingFileExists = true
}
- startHeartbeatInBackground()
+ DispatchQueue.main.async {
+ startHeartbeatInBackground(requireVPNConnection: false)
+ }
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in
DispatchQueue.main.async {
@@ -282,7 +289,7 @@ struct HomeView: View {
var autoScriptData: Data? = nil
var autoScriptName: String? = nil
- if let scriptInfo = autoScript(for: selectedBundle) {
+ if let scriptInfo = preferredScript(for: selectedBundle) {
autoScriptData = scriptInfo.data
autoScriptName = scriptInfo.name
}
@@ -294,6 +301,12 @@ struct HomeView: View {
triggeredByURLScheme: false)
}
}
+ .pipify(isPresented: Binding(
+ get: { pipRequired && enablePiP },
+ set: { pipRequired = $0 }
+ )) {
+ RunJSViewPiP(model: $jsModel)
+ }
.sheet(isPresented: $scriptViewShow) {
NavigationView {
if let jsModel {
@@ -340,7 +353,7 @@ struct HomeView: View {
config.scriptName = scriptName
}
if config.scriptData == nil, let bundleID = config.bundleID,
- let scriptInfo = autoScript(for: bundleID) {
+ let scriptInfo = preferredScript(for: bundleID) {
config.scriptData = scriptInfo.data
config.scriptName = scriptInfo.name
}
@@ -358,8 +371,8 @@ struct HomeView: View {
let nameRaw = pinnedSystemAppNames[bundleId] ?? friendlyName(for: bundleId)
let name = shortDisplayName(from: nameRaw)
systemLaunchMessage = success
- ? String(format: "Launch requested: %@".localized, name)
- : String(format: "Failed to launch %@".localized, name)
+ ? String(format: "Launch requested: %@".localized, name)
+ : String(format: "Failed to launch %@".localized, name)
scheduleSystemToastDismiss()
}
}
@@ -392,7 +405,7 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
-
+
private var setupCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 16) {
@@ -402,22 +415,22 @@ struct HomeView: View {
Spacer()
connectionStatusBadge
}
-
+
statusLightsRow
-
+
vpnControls
-
+
}
}
}
-
+
private var connectCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 16) {
Text("Connect")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
-
+
if shouldPromptForWiFi {
statusBadge(
icon: "wifi.slash",
@@ -431,15 +444,15 @@ struct HomeView: View {
color: .red
)
}
-
+
primaryActionControls
-
+
if let info = connectionInfoMessage, !info.isEmpty {
Text(info)
.font(.caption)
.foregroundStyle(.secondary)
}
-
+
if isImportingFile {
pairingImportProgressView
} else if showPairingFileMessage && pairingFileIsValid {
@@ -448,9 +461,9 @@ struct HomeView: View {
}
}
}
-
+
// MARK: - Connection Setup Helpers
-
+
@ViewBuilder
private var connectionStatusBadge: some View {
if isConnectionCheckRunning {
@@ -463,7 +476,7 @@ struct HomeView: View {
statusBadge(icon: "circle.lefthalf.filled", text: "Not ready", color: .yellow)
}
}
-
+
private func statusBadge(icon: String, text: String, color: Color) -> some View {
Label(text, systemImage: icon)
.font(.footnote.weight(.semibold))
@@ -475,27 +488,35 @@ struct HomeView: View {
)
.foregroundStyle(color)
}
-
+
private var connectionHasError: Bool {
if case .failure = connectionCheckState { return true }
if case .timeout = connectionCheckState { return true }
return false
}
-
+
private var allStatusIndicatorsGreen: Bool {
ddiIndicatorStatus == .success &&
wifiIndicatorStatus == .success &&
heartbeatIndicatorStatus == .success
}
-
+
private var statusLightsRow: some View {
HStack(spacing: 12) {
ForEach(statusLights) { light in
- StatusLightView(light: light)
+ if let action = light.action {
+ Button(action: action) {
+ StatusLightView(light: light)
+ }
+ .buttonStyle(.plain)
+ .disabled(!light.isEnabled)
+ } else {
+ StatusLightView(light: light)
+ }
}
}
}
-
+
private var statusLights: [StatusLightData] {
[
StatusLightData(
@@ -518,40 +539,69 @@ struct HomeView: View {
icon: "waveform.path.ecg",
status: heartbeatIndicatorStatus,
detail: heartbeatDetailText
+ ),
+ StatusLightData(
+ type: .refresh,
+ title: "Refresh",
+ icon: "arrow.clockwise",
+ status: refreshIndicatorStatus,
+ detail: "",
+ action: refreshStatusTapped,
+ isEnabled: !isConnectionCheckRunning,
+ indicatorIconName: "arrow.clockwise",
+ indicatorColor: .blue,
+ tintOverride: .blue
)
]
}
-
+
private var ddiDetailText: String {
if ddiMounted { return "Mounted" }
if pairingFileLikelyInvalid { return "Error" }
return pairingFileExists ? "Mount required" : "Not ready"
}
-
+
private var wifiDetailText: String {
if isConnectionCheckRunning { return "Checking…" }
return wifiConnected ? "Connected" : "Offline"
}
-
+
private var heartbeatDetailText: String {
if heartbeatOK { return "Active" }
if pairingFileExists {
- return tunnel.tunnelStatus == .connected ? "Waiting" : "VPN required"
+ if requiresLoopbackVPN {
+ return tunnel.tunnelStatus == .connected ? "Waiting" : "VPN required"
+ } else {
+ return "Waiting"
+ }
}
return "Pair first"
}
+ private var refreshIndicatorStatus: StartupIndicatorStatus {
+ switch connectionCheckState {
+ case .running:
+ return .running
+ case .success:
+ return .success
+ case .failure, .timeout:
+ return .warning
+ case .idle:
+ return .idle
+ }
+ }
+
private var ddiIndicatorStatus: StartupIndicatorStatus {
if ddiMounted { return .success }
if pairingFileLikelyInvalid { return .warning }
if pairingFileExists { return .warning }
return .idle
}
-
+
private func color(for indicator: StartupIndicatorStatus) -> Color {
indicator.tint
}
-
+
private func startWiFiMonitoring() {
guard wifiMonitor == nil else { return }
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
@@ -563,12 +613,12 @@ struct HomeView: View {
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
-
+
private func stopWiFiMonitoring() {
wifiMonitor?.cancel()
wifiMonitor = nil
}
-
+
private func startCellularMonitoring() {
guard cellularMonitor == nil else { return }
let monitor = NWPathMonitor(requiredInterfaceType: .cellular)
@@ -580,13 +630,13 @@ struct HomeView: View {
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
-
+
private func stopCellularMonitoring() {
cellularMonitor?.cancel()
cellularMonitor = nil
isCellularActive = false
}
-
+
private var pairingStatusDescription: String {
if isValidatingPairingFile { return "Validating pairing file…" }
if pairingFileExists {
@@ -597,18 +647,21 @@ struct HomeView: View {
}
return "Import the pairing file generated from your trusted computer."
}
-
+
private var wifiStatusDescription: String {
if isConnectionCheckRunning { return "Checking Wi-Fi status…" }
return wifiConnected ? "Wi-Fi connected and ready." : "Connect to Wi-Fi."
}
-
+
private var isConnectionCheckRunning: Bool {
if case .running = connectionCheckState { return true }
return false
}
-
+
private var vpnStatusSubtitle: String {
+ if !requiresLoopbackVPN {
+ return "External device selected. VPN tunnel not required."
+ }
if isConnectionCheckRunning {
return "Checking the VPN/loopback tunnel…"
}
@@ -625,8 +678,9 @@ struct HomeView: View {
return "VPN configuration error. Try reconnecting."
}
}
-
+
private var vpnIndicatorStatus: StartupIndicatorStatus {
+ if !requiresLoopbackVPN { return .success }
if isConnectionCheckRunning { return .running }
switch tunnel.tunnelStatus {
case .connected:
@@ -639,16 +693,19 @@ struct HomeView: View {
return .error
}
}
-
+
private var wifiIndicatorStatus: StartupIndicatorStatus {
if isConnectionCheckRunning { return .running }
return wifiConnected ? .success : .warning
}
-
+
private var heartbeatSubtitle: String {
if heartbeatOK {
return "Heartbeat is responding."
}
+ if !requiresLoopbackVPN && pairingFileExists {
+ return "Waiting for the remote device to respond."
+ }
if pairingFileLikelyInvalid {
return "Heartbeat is blocked because the pairing file looks invalid."
}
@@ -663,7 +720,7 @@ struct HomeView: View {
}
return "Heartbeat runs after the connection check completes."
}
-
+
private var heartbeatIndicatorStatus: StartupIndicatorStatus {
if heartbeatOK { return .success }
if pairingFileLikelyInvalid { return .warning }
@@ -672,7 +729,7 @@ struct HomeView: View {
if case .success = connectionCheckState { return .warning }
return .idle
}
-
+
private var connectionCheckButtonLabel: some View {
compactControlButton(
icon: "waveform.path.ecg",
@@ -680,7 +737,7 @@ struct HomeView: View {
showSpinner: isConnectionCheckRunning
)
}
-
+
private var primaryActionControls: some View {
VStack(spacing: 8) {
Button(action: primaryActionTapped) {
@@ -691,7 +748,7 @@ struct HomeView: View {
)
}
.disabled(isProcessing || isValidatingPairingFile)
-
+
if pairingFileExists && enableAdvancedOptions && !pairingFileLikelyInvalid && primaryActionTitle == "Connect by App" {
Button(action: { showPIDSheet = true }) {
secondaryButtonLabel(icon: "number.circle", title: "Connect by PID")
@@ -700,1301 +757,1386 @@ struct HomeView: View {
}
}
}
-
+
+ @ViewBuilder
private var vpnControls: some View {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("VPN Tunnel")
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(.primary)
- Spacer()
- statusBadge(icon: "shield.lefthalf.filled", text: tunnel.tunnelStatus.rawValue, color: vpnStatusColor)
+ if !requiresLoopbackVPN {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("VPN Tunnel")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ statusBadge(icon: "checkmark.circle.fill", text: "Not needed", color: .green)
+ }
+ Text("You’re targeting an external device. StikDebug connects directly to \(DeviceConnectionContext.targetIPAddress).")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
}
-
- Text(vpnStatusSubtitle)
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- HStack(spacing: 8) {
- Button(action: { TunnelManager.shared.startVPN() }) {
- compactControlButton(icon: "lock.open", title: "Connect")
+ } else {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("VPN Tunnel")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ statusBadge(icon: "shield.lefthalf.filled", text: tunnel.tunnelStatus.rawValue, color: vpnStatusColor)
}
- .buttonStyle(.plain)
- .disabled(!canStartVPN)
-
- Button(action: { TunnelManager.shared.stopVPN() }) {
- compactControlButton(icon: "lock.fill", title: "Disconnect")
+
+ Text(vpnStatusSubtitle)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ Button(action: { TunnelManager.shared.startVPN() }) {
+ compactControlButton(icon: "lock.open", title: "Connect")
+ }
+ .buttonStyle(.plain)
+ .disabled(!canStartVPN)
+
+ Button(action: { TunnelManager.shared.stopVPN() }) {
+ compactControlButton(icon: "lock.fill", title: "Disconnect")
+ }
+ .buttonStyle(.plain)
+ .disabled(!canStopVPN)
+ }
+
+ Button {
+ autoStartVPN.toggle()
+ if requiresLoopbackVPN && autoStartVPN && tunnel.tunnelStatus == .disconnected {
+ TunnelManager.shared.startVPN()
+ }
+ } label: {
+ compactControlButton(
+ icon: autoStartVPN ? "lock.circle" : "lock.slash",
+ title: autoStartVPN ? "Disable Auto VPN" : "Enable Auto VPN"
+ )
}
.buttonStyle(.plain)
- .disabled(!canStopVPN)
- }
-
- Button {
- autoStartVPN.toggle()
- if autoStartVPN && tunnel.tunnelStatus == .disconnected {
- TunnelManager.shared.startVPN()
- }
- } label: {
- compactControlButton(
- icon: autoStartVPN ? "lock.circle" : "lock.slash",
- title: autoStartVPN ? "Disable Auto VPN" : "Enable Auto VPN"
- )
}
- .buttonStyle(.plain)
}
}
-
+
private var vpnStatusColor: Color {
+ if !requiresLoopbackVPN { return .green }
switch tunnel.tunnelStatus {
case .connected: return .green
- case .connecting, .disconnecting: return .orange
- case .error: return .red
- case .disconnected: return .yellow
+ case .connecting, .disconnecting: return .orange
+ case .error: return .red
+ case .disconnected: return .yellow
+ }
}
- }
-
- private var canStartVPN: Bool {
- switch tunnel.tunnelStatus {
- case .disconnected, .error:
- return true
- default:
- return false
+
+ private var canStartVPN: Bool {
+ guard requiresLoopbackVPN else { return false }
+ switch tunnel.tunnelStatus {
+ case .disconnected, .error:
+ return true
+ default:
+ return false
+ }
}
- }
-
- private var canStopVPN: Bool {
- switch tunnel.tunnelStatus {
- case .connected, .connecting, .disconnecting:
- return true
- default:
- return false
+
+ private var canStopVPN: Bool {
+ guard requiresLoopbackVPN else { return false }
+ switch tunnel.tunnelStatus {
+ case .connected, .connecting, .disconnecting:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private func refreshStatusTapped() {
+ runConnectionDiagnostics()
+ if pairingFileExists {
+ startHeartbeatInBackground(requireVPNConnection: requiresLoopbackVPN)
}
}
-
+
private func runConnectionDiagnostics(autoStart: Bool = false) {
guard !isConnectionCheckRunning else { return }
- connectionTimeoutTask?.cancel()
- connectionTimeoutTask = nil
- connectionInfoMessage = autoStart ? "Checking connection…" : nil
- connectionCheckState = .running
- let timeout = DispatchWorkItem {
- DispatchQueue.main.async {
- if case .running = connectionCheckState {
- connectionCheckState = .timeout
- connectionInfoMessage = "Connection timed out. Check the VPN and pairing file, then try again."
- connectionTimeoutTask = nil
- }
- }
- }
- connectionTimeoutTask = timeout
- DispatchQueue.main.asyncAfter(deadline: .now() + 7, execute: timeout)
-
- checkVPNConnection { success, error in
connectionTimeoutTask?.cancel()
connectionTimeoutTask = nil
- if success {
- connectionCheckState = .success
- connectionInfoMessage = nil
- if pairingFileExists && !heartbeatOK {
- startHeartbeatInBackground()
- }
- } else {
- connectionCheckState = .failure(error ?? "VPN tunnel is not connected.")
- connectionInfoMessage = error ?? "VPN tunnel is not connected."
- }
- }
- }
-
- private var primaryActionTitle: String {
- if isValidatingPairingFile { return "Validating…" }
- if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" }
- if shouldPromptForWiFi { return "Connect to Wi-Fi" }
- if pairingFileLikelyInvalid { return "New Pairing File Needed" }
- if !ddiMounted { return "Mount Developer Disk Image" }
- return "Connect by App"
- }
-
- private var primaryActionIcon: String {
- if isValidatingPairingFile { return "hourglass" }
- if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" }
- if shouldPromptForWiFi { return "wifi.slash" }
- if pairingFileLikelyInvalid { return "arrow.clockwise" }
- if !ddiMounted { return "externaldrive" }
- return "cable.connector.horizontal"
- }
-
- private var pairingImportProgressView: some View {
- VStack(spacing: 8) {
- HStack {
- Text("Processing pairing file…")
- .font(.system(.caption, design: .rounded))
- .foregroundStyle(.secondary)
- Spacer()
- Text("\(Int(importProgress * 100))%")
- .font(.system(.caption, design: .rounded))
- .foregroundStyle(.secondary)
- }
-
- GeometryReader { geo in
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color(UIColor.tertiarySystemFill))
- .frame(height: 8)
-
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(accentColor)
- .frame(width: geo.size.width * CGFloat(importProgress), height: 8)
- .animation(.linear(duration: 0.25), value: importProgress)
+ connectionInfoMessage = autoStart ? "Checking connection…" : nil
+ connectionCheckState = .running
+ let timeout = DispatchWorkItem {
+ DispatchQueue.main.async {
+ if case .running = connectionCheckState {
+ connectionCheckState = .timeout
+ connectionInfoMessage = requiresLoopbackVPN
+ ? "Connection timed out. Check the VPN and pairing file, then try again."
+ : "Connection timed out. Make sure the device at \(DeviceConnectionContext.targetIPAddress) is reachable."
+ connectionTimeoutTask = nil
+ }
}
}
- .frame(height: 8)
- }
- .accessibilityElement(children: .combine)
- }
-
- private var pairingSuccessMessage: some View {
- HStack(spacing: 10) {
- StatusDot(color: .green)
- Text("Pairing file successfully imported")
- .font(.system(.callout, design: .rounded))
- .foregroundStyle(.green)
- Spacer(minLength: 0)
- }
- .padding(.top, 4)
- .transition(.opacity)
- }
-
- private func whiteCardButtonLabel(icon: String, title: String, isLoading: Bool = false) -> some View {
- HStack(spacing: 10) {
- if isLoading {
- ProgressView()
- .progressViewStyle(.circular)
- .tint(accentColor.contrastText())
- .frame(width: 20, height: 20)
- } else {
- Image(systemName: icon)
- .font(.system(size: 20, weight: .semibold, design: .rounded))
- }
-
- Text(title)
- .font(.system(.title3, design: .rounded).weight(.semibold))
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 14)
- .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
- .foregroundColor(accentColor.contrastText())
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
- )
- .animation(.easeInOut(duration: 0.2), value: isLoading)
- }
-
- private func secondaryButtonLabel(icon: String, title: String) -> some View {
- HStack {
- Image(systemName: icon)
- .font(.system(size: 18, weight: .semibold, design: .rounded))
- Text(title)
- .font(.system(.title3, design: .rounded).weight(.semibold))
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.12), lineWidth: 1)
- )
- .foregroundStyle(.primary)
- }
-
- private var quickConnectCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 14) {
- HStack(spacing: 8) {
- Text("Quick Connect")
- .font(.headline.weight(.semibold))
- .foregroundStyle(.primary)
-
- }
-
- Text("Favorites and recents stay within reach so you can enable debug with ease.")
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- if quickConnectItems.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- Text("Pin apps from the Installed Apps list to see them here.")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Button {
- isShowingInstalledApps = true
- } label: {
- secondaryButtonLabel(icon: "star", title: "Choose Favorites")
- }
- .buttonStyle(.plain)
+ connectionTimeoutTask = timeout
+ DispatchQueue.main.asyncAfter(deadline: .now() + 7, execute: timeout)
+
+ checkVPNConnection { success, error in
+ connectionTimeoutTask?.cancel()
+ connectionTimeoutTask = nil
+ if success {
+ connectionCheckState = .success
+ connectionInfoMessage = nil
+ if pairingFileExists && !heartbeatOK {
+ startHeartbeatInBackground()
}
} else {
- VStack(spacing: 10) {
- ForEach(quickConnectItems) { item in
- QuickConnectRow(
- item: item,
- accentColor: accentColor,
- isEnabled: canConnectByApp && !isProcessing,
- action: {
- HapticFeedbackHelper.trigger()
- startJITInBackground(bundleID: item.bundleID,
- pid: nil,
- scriptData: nil,
- scriptName: nil,
- triggeredByURLScheme: false)
- }
- )
- }
- }
- }
-
- if !canConnectByApp {
- Text("Finish the pairing and mounting steps above to enable quick launches.")
- .font(.caption)
- .foregroundStyle(.secondary)
+ let fallback = requiresLoopbackVPN ? "VPN tunnel is not connected." : "Unable to reach the device."
+ connectionCheckState = .failure(error ?? fallback)
+ connectionInfoMessage = error ?? fallback
}
}
}
- }
-
- private var launchShortcutsCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 14) {
- Text("Launch Shortcuts".localized)
- .font(.headline.weight(.semibold))
- .foregroundStyle(.primary)
-
- Text("Pin any app from Installed Apps and launch it here with ease.".localized)
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- VStack(spacing: 10) {
- ForEach(pinnedLaunchItems) { item in
- SystemPinnedRow(
- item: item,
- accentColor: accentColor,
- isLaunching: launchingSystemApps.contains(item.bundleID),
- action: { launchSystemApp(item: item) },
- onRemove: { removePinnedSystemApp(bundleID: item.bundleID) }
- )
+
+ private var primaryActionTitle: String {
+ if isValidatingPairingFile { return "Validating…" }
+ if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" }
+ if shouldPromptForWiFi { return "Connect to Wi-Fi" }
+ if pairingFileLikelyInvalid { return "New Pairing File Needed" }
+ if !ddiMounted { return "Mount Developer Disk Image" }
+ return "Connect by App"
+ }
+
+ private var primaryActionIcon: String {
+ if isValidatingPairingFile { return "hourglass" }
+ if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" }
+ if shouldPromptForWiFi { return "wifi.slash" }
+ if pairingFileLikelyInvalid { return "arrow.clockwise" }
+ if !ddiMounted { return "externaldrive" }
+ return "cable.connector.horizontal"
+ }
+
+ private var pairingImportProgressView: some View {
+ VStack(spacing: 8) {
+ HStack {
+ Text("Processing pairing file…")
+ .font(.system(.caption, design: .rounded))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(Int(importProgress * 100))%")
+ .font(.system(.caption, design: .rounded))
+ .foregroundStyle(.secondary)
+ }
+
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color(UIColor.tertiarySystemFill))
+ .frame(height: 8)
+
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(accentColor)
+ .frame(width: geo.size.width * CGFloat(importProgress), height: 8)
+ .animation(.linear(duration: 0.25), value: importProgress)
}
}
+ .frame(height: 8)
}
+ .accessibilityElement(children: .combine)
}
- }
-
- private var quickConnectItems: [QuickConnectItem] {
- var seen = Set()
- var ordered: [QuickConnectItem] = []
- for bundle in favoriteApps + recentApps {
- guard seen.insert(bundle).inserted else { continue }
- ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle)))
- if ordered.count >= 4 { break }
- }
- return ordered
- }
-
- private var pinnedLaunchItems: [SystemPinnedItem] {
- pinnedSystemApps.compactMap { bundleID in
- let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
- let displayName = shortDisplayName(from: raw)
- return SystemPinnedItem(bundleID: bundleID, displayName: displayName)
- }
- }
-
- // Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component.
- private func friendlyName(for bundleID: String) -> String {
- if let cached = cachedAppNames[bundleID], !cached.isEmpty {
- return shortDisplayName(from: cached)
- }
- 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
- }
-
- // Heuristic “Home Screen” shortener for long marketing names.
- private func shortDisplayName(from name: String) -> String {
- var s = name
-
- // Keep only the part before common separators/subtitles.
- let separators = [" — ", " – ", " - ", ":", "|", "·", "•"]
- for sep in separators {
- if let r = s.range(of: sep) {
- s = String(s[.. some View {
+ HStack(spacing: 10) {
+ if isLoading {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .tint(accentColor.contrastText())
+ .frame(width: 20, height: 20)
+ } else {
+ Image(systemName: icon)
+ .font(.system(size: 20, weight: .semibold, design: .rounded))
}
+
+ Text(title)
+ .font(.system(.title3, design: .rounded).weight(.semibold))
}
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .foregroundColor(accentColor.contrastText())
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
+ )
+ .animation(.easeInOut(duration: 0.2), value: isLoading)
}
- }
-
- private func loadAppListIfNeeded(force: Bool = false) {
- guard pairingFileExists else {
- cachedAppNames = [:]
- cachedAppNamesData = nil
- return
- }
-
- guard tunnel.tunnelStatus == .connected else { return }
-
- if !force && !cachedAppNames.isEmpty { return }
-
- DispatchQueue.global(qos: .userInitiated).async {
- let result = (try? JITEnableContext.shared.getAppList()) ?? [:]
- let encoded = try? JSONEncoder().encode(result)
- DispatchQueue.main.async {
- cachedAppNames = result
- syncFavoriteAppNamesWithCache()
- cachedAppNamesData = encoded
+
+ private func secondaryButtonLabel(icon: String, title: String) -> some View {
+ HStack {
+ Image(systemName: icon)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ Text(title)
+ .font(.system(.title3, design: .rounded).weight(.semibold))
}
- }
- }
-
- private func homeCard(@ViewBuilder content: () -> Content) -> some View {
- content()
- .padding(20)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
.background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
)
- .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
- }
-
- private func compactControlButton(icon: String, title: String, showSpinner: Bool = false) -> some View {
- HStack(spacing: 6) {
- if showSpinner {
- ProgressView()
- .progressViewStyle(.circular)
- .controlSize(.small)
- } else {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .semibold, design: .rounded))
- }
- Text(title)
- .font(.caption.weight(.semibold))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.12), lineWidth: 1)
+ )
+ .foregroundStyle(.primary)
}
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .frame(maxWidth: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.8))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
- }
-
- private var tipsCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 12) {
- Text("Tips")
- .font(.headline)
- .foregroundStyle(.secondary)
-
- if !pairingFileExists {
- tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
- }
- if pairingFileExists && !ddiMounted {
- tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Ensure your pairing is imported and valid, connect to Wi-Fi and force-restart StikDebug.")
- }
- tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
-
- Divider().background(Color.white.opacity(0.1))
-
- Button {
- if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
- UIApplication.shared.open(url)
+
+ private var quickConnectCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 8) {
+ Text("Quick Connect")
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.primary)
+
}
- } label: {
- HStack(alignment: .center, spacing: 12) {
- Image(systemName: "questionmark.circle")
- .foregroundStyle(accentColor)
- .font(.system(size: 18, weight: .semibold))
-
- VStack(alignment: .leading, spacing: 2) {
- Text("Pairing File Guide")
- .font(.subheadline.weight(.semibold))
- Text("Step-by-step instructions from the community wiki.")
- .font(.footnote)
+
+ Text("Favorites and recents stay within reach so you can enable debug with ease.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ if quickConnectItems.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Pin apps from the Installed Apps list to see them here.")
+ .font(.caption)
.foregroundStyle(.secondary)
+
+ Button {
+ isShowingInstalledApps = true
+ } label: {
+ secondaryButtonLabel(icon: "star", title: "Choose Favorites")
+ }
+ .buttonStyle(.plain)
}
-
- Spacer()
- Image(systemName: "chevron.right")
- .font(.system(size: 14, weight: .semibold))
+ } else {
+ VStack(spacing: 10) {
+ ForEach(quickConnectItems) { item in
+ QuickConnectRow(
+ item: item,
+ accentColor: accentColor,
+ isEnabled: canConnectByApp && !isProcessing,
+ action: {
+ HapticFeedbackHelper.trigger()
+ let scriptInfo = preferredScript(for: item.bundleID)
+ startJITInBackground(bundleID: item.bundleID,
+ pid: nil,
+ scriptData: scriptInfo?.data,
+ scriptName: scriptInfo?.name,
+ triggeredByURLScheme: false)
+ }
+ )
+ }
+ }
+ }
+
+ if !canConnectByApp {
+ Text("Finish the pairing and mounting steps above to enable quick launches.")
+ .font(.caption)
.foregroundStyle(.secondary)
}
- .padding(.vertical, 4)
}
- .buttonStyle(.plain)
- }
- }
- }
-
- private func tipRow(systemImage: String, title: String, message: String) -> some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: systemImage)
- .foregroundStyle(accentColor)
- .font(.system(size: 18, weight: .semibold))
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.subheadline.weight(.semibold))
- Text(message)
- .font(.footnote)
- .foregroundStyle(.secondary)
}
- Spacer(minLength: 0)
}
- .padding(.vertical, 4)
- }
-
- private func primaryActionTapped() {
- guard !isValidatingPairingFile else { return }
- if pairingFileLikelyInvalid {
- if shouldPromptForWiFi {
- showAlert(
- title: "Wi-Fi Required",
- message: "Connect to Wi-Fi.",
- showOk: true
- ) { _ in }
- } else {
- isShowingPairingFilePicker = true
+
+ private var launchShortcutsCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Launch Shortcuts".localized)
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ Text("Pin any app from Installed Apps and launch it here with ease.".localized)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ VStack(spacing: 10) {
+ ForEach(pinnedLaunchItems) { item in
+ SystemPinnedRow(
+ item: item,
+ accentColor: accentColor,
+ isLaunching: launchingSystemApps.contains(item.bundleID),
+ action: { launchSystemApp(item: item) },
+ onRemove: { removePinnedSystemApp(bundleID: item.bundleID) }
+ )
+ }
+ }
+ }
}
- return
}
- if pairingFileExists {
- if !ddiMounted {
- showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
- return
+
+ private var quickConnectItems: [QuickConnectItem] {
+ var seen = Set()
+ var ordered: [QuickConnectItem] = []
+ for bundle in favoriteApps + recentApps {
+ guard seen.insert(bundle).inserted else { continue }
+ ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle)))
+ if ordered.count >= 4 { break }
}
- isShowingInstalledApps = true
- } else {
- isShowingPairingFilePicker = true
- }
- }
-
- private func showCopiedToast() {
- withAnimation { justCopied = true }
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- withAnimation { justCopied = false }
- }
- }
-
- @ViewBuilder private func toast(_ text: String) -> some View {
- VStack {
- Spacer()
- Text(text)
- .font(.footnote.weight(.semibold))
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .background(.ultraThinMaterial, in: Capsule())
- .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
- .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
- .transition(.move(edge: .bottom).combined(with: .opacity))
- .padding(.bottom, 30)
- }
- .animation(.easeInOut(duration: 0.25), value: text)
- }
-
- private func checkPairingFileExists() {
- let fileExists = FileManager.default.fileExists(atPath: pairingFileURL.path)
- pairingFilePresentOnDisk = fileExists
-
- guard fileExists else {
- pairingFileExists = false
- lastValidatedPairingSignature = nil
- isValidatingPairingFile = false
- return
+ return ordered
}
-
- let signature = pairingFileSignature(for: pairingFileURL)
-
- guard needsValidation(for: signature) else { return }
- guard !isValidatingPairingFile else { return }
-
- isValidatingPairingFile = true
-
- DispatchQueue.global(qos: .utility).async {
- let valid = isPairing()
- DispatchQueue.main.async {
- pairingFileExists = valid
- lastValidatedPairingSignature = signature
- isValidatingPairingFile = false
+
+ private var pinnedLaunchItems: [SystemPinnedItem] {
+ pinnedSystemApps.compactMap { bundleID in
+ let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
+ let displayName = shortDisplayName(from: raw)
+ return SystemPinnedItem(bundleID: bundleID, displayName: displayName)
}
}
- }
-
- private func needsValidation(for signature: PairingFileSignature) -> Bool {
- guard let lastSignature = lastValidatedPairingSignature else { return true }
- return lastSignature != signature
- }
-
-
- private func pairingFileSignature(for url: URL) -> PairingFileSignature {
- let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
- let modificationDate = attributes[.modificationDate] as? Date
- let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
- return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue)
- }
- private func refreshBackground() { }
-
- private 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),
- let url = Bundle.main.url(forResource: resource.resource, withExtension: "js"),
- let data = try? Data(contentsOf: url) else {
- return nil
- }
- return (data, resource.fileName)
- }
-
- private 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 func syncFavoriteAppNamesWithCache() {
- guard let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") else { return }
- let favorites = sharedDefaults.stringArray(forKey: "favoriteApps") ?? []
- guard !favorites.isEmpty else { return }
-
- var storedNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:]
- var changed = false
-
- for bundle in favorites {
- guard let rawName = cachedAppNames[bundle], !rawName.isEmpty else { continue }
- let display = shortDisplayName(from: rawName)
- if storedNames[bundle] != display {
- storedNames[bundle] = display
- changed = true
+
+ // Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component.
+ private func friendlyName(for bundleID: String) -> String {
+ if let cached = cachedAppNames[bundleID], !cached.isEmpty {
+ return shortDisplayName(from: cached)
}
- }
-
- if changed {
- sharedDefaults.set(storedNames, forKey: "favoriteAppNames")
- WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
- }
- }
-
- private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
- switch appName {
- case "maciOS":
- return ("script1", "script1.js")
- case "Amethyst":
- return ("script2", "script2.js")
- case "Geode":
- return ("Geode", "Geode.js")
- case "MeloNX":
- return ("melo", "melo.js")
- case "UTM", "DolphiniOS":
- return ("utmjit", "utmjit.js")
- default:
- return nil
- }
- }
-
- private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
- return { pid, debugProxyHandle, semaphore in
- jsModel = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, semaphore: semaphore)
- scriptViewShow = true
- DispatchQueue.global(qos: .background).async {
- do { try jsModel?.runScript(data: script, name: name) }
- catch { showAlert(title: "Error Occurred While Executing the Default Script.".localized, message: error.localizedDescription, showOk: true) }
+ 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
}
- }
-
- private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
- isProcessing = true
- LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
- DispatchQueue.global(qos: .background).async {
- var scriptData = scriptData
- var scriptName = scriptName
- if enableAdvancedOptions && scriptData == nil {
- if scriptName == nil, let bundleID, let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] {
- scriptName = mapping[bundleID]
- }
- if useDefaultScript && scriptName == nil { scriptName = selectedScript }
- if scriptData == nil, let scriptName {
- let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- .appendingPathComponent("scripts").appendingPathComponent(scriptName)
- if FileManager.default.fileExists(atPath: url.path) {
- do { scriptData = try Data(contentsOf: url) } catch { print("script load error: \(error)") }
- }
+ // Heuristic “Home Screen” shortener for long marketing names.
+ private func shortDisplayName(from name: String) -> String {
+ var s = name
+
+ // Keep only the part before common separators/subtitles.
+ let separators = [" — ", " – ", " - ", ":", "|", "·", "•"]
+ for sep in separators {
+ if let r = s.range(of: sep) {
+ s = String(s[..(@ViewBuilder content: () -> Content) -> 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)
+ )
+ )
+ .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
+ }
+
+ private func compactControlButton(icon: String, title: String, showSpinner: Bool = false) -> some View {
+ HStack(spacing: 6) {
+ if showSpinner {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.small)
} else {
- LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)")
- systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName)
+ Image(systemName: icon)
+ .font(.system(size: 14, weight: .semibold, design: .rounded))
}
- scheduleSystemToastDismiss()
+ Text(title)
+ .font(.caption.weight(.semibold))
}
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ .frame(maxWidth: .infinity)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.8))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
}
- }
-
- private func removePinnedSystemApp(bundleID: String) {
- Haptics.light()
- pinnedSystemApps.removeAll { $0 == bundleID }
- pinnedSystemAppNames.removeValue(forKey: bundleID)
- persistPinnedSystemApps()
- }
-
- private func scheduleSystemToastDismiss() {
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
- if systemLaunchMessage != nil {
- withAnimation {
- systemLaunchMessage = nil
+
+ private var tipsCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Tips")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ if !pairingFileExists {
+ tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
+ }
+ if pairingFileExists && !ddiMounted {
+ tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Ensure your pairing is imported and valid, connect to Wi-Fi and force-restart StikDebug.")
+ }
+ tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
+
+ Divider().background(Color.white.opacity(0.1))
+
+ Button {
+ if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
+ UIApplication.shared.open(url)
+ }
+ } label: {
+ HStack(alignment: .center, spacing: 12) {
+ Image(systemName: "questionmark.circle")
+ .foregroundStyle(accentColor)
+ .font(.system(size: 18, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Pairing File Guide")
+ .font(.subheadline.weight(.semibold))
+ Text("Step-by-step instructions from the community wiki.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+ .buttonStyle(.plain)
}
}
}
- }
-
- private func persistPinnedSystemApps() {
- if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") {
- sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
- sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
- }
- WidgetCenter.shared.reloadAllTimelines()
- }
-
- private func addRecentPID(_ pid: Int) {
- var list = recentPIDs.filter { $0 != pid }
- list.insert(pid, at: 0)
- if list.count > 8 { list = Array(list.prefix(8)) }
- recentPIDs = list
- }
-
- func base64URLToBase64(_ base64url: String) -> String {
- var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
- let pad = 4 - (base64.count % 4)
- if pad < 4 { base64 += String(repeating: "=", count: pad) }
- return base64
- }
-
-private struct StatusDot: View {
- var color: Color
- @Environment(\.colorScheme) private var colorScheme
- var body: some View {
- ZStack {
- Circle().fill(color.opacity(0.25)).frame(width: 20, height: 20)
- Circle().fill(color).frame(width: 12, height: 12)
- .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
- }
- .overlay(
- Circle().stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
- )
- }
-}
-
-private struct StatusGlyph: View {
- let icon: String
- let tint: Color
- var size: CGFloat = 48
- var iconSize: CGFloat = 22
-
- var body: some View {
- ZStack {
- Circle()
- .fill(tint.opacity(0.18))
- .frame(width: size, height: size)
-
- Image(systemName: icon)
- .font(.system(size: iconSize, weight: .semibold, design: .rounded))
- .foregroundStyle(tint)
- }
- .overlay(
- Circle()
- .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
- )
- }
-}
-
-private struct QuickConnectRow: View {
- let item: QuickConnectItem
- let accentColor: Color
- let isEnabled: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 14) {
- QuickAppBadge(title: item.displayName, accentColor: accentColor)
-
+
+ private func tipRow(systemImage: String, title: String, message: String) -> some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(accentColor)
+ .font(.system(size: 18, weight: .semibold))
VStack(alignment: .leading, spacing: 2) {
- Text(item.displayName)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .foregroundStyle(.primary)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
-
- Text(item.bundleID)
- .font(.caption)
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ Text(message)
+ .font(.footnote)
.foregroundStyle(.secondary)
- .lineLimit(1)
- .textSelection(.enabled)
}
-
Spacer(minLength: 0)
-
- Image(systemName: "bolt.horizontal.circle.fill")
- .font(.system(size: 20, weight: .semibold))
- .foregroundStyle(isEnabled ? accentColor : Color.secondary)
}
- .padding(.vertical, 12)
- .padding(.horizontal, 14)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
+ .padding(.vertical, 4)
}
- .buttonStyle(.plain)
- .disabled(!isEnabled)
- .opacity(isEnabled ? 1 : 0.55)
- }
-}
-
-private struct SystemPinnedRow: View {
- let item: SystemPinnedItem
- let accentColor: Color
- let isLaunching: Bool
- var action: () -> Void
- var onRemove: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 14) {
- QuickAppBadge(title: item.displayName, accentColor: accentColor)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(item.displayName)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .foregroundStyle(.primary)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
-
- Text(item.bundleID)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .textSelection(.enabled)
- }
-
- Spacer(minLength: 0)
-
- if isLaunching {
- ProgressView()
- .controlSize(.small)
- .tint(accentColor)
+
+ private func primaryActionTapped() {
+ guard !isValidatingPairingFile else { return }
+ if pairingFileLikelyInvalid {
+ if shouldPromptForWiFi {
+ showAlert(
+ title: "Wi-Fi Required",
+ message: "Connect to Wi-Fi.",
+ showOk: true
+ ) { _ in }
} else {
- Image(systemName: "play.fill")
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(accentColor)
+ isShowingPairingFilePicker = true
+ }
+ return
+ }
+ if pairingFileExists {
+ if !ddiMounted {
+ showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
+ return
}
+ isShowingInstalledApps = true
+ } else {
+ isShowingPairingFilePicker = true
}
- .padding(.vertical, 12)
- .padding(.horizontal, 14)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
}
- .buttonStyle(.plain)
- .disabled(isLaunching)
- .contextMenu {
- Button("Remove from Home".localized, systemImage: "star.slash") {
- onRemove()
+
+ private func showCopiedToast() {
+ withAnimation { justCopied = true }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ withAnimation { justCopied = false }
}
}
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button(role: .destructive) {
- onRemove()
- } label: {
- Label("Remove".localized, systemImage: "trash")
+
+ @ViewBuilder private func toast(_ text: String) -> some View {
+ VStack {
+ Spacer()
+ Text(text)
+ .font(.footnote.weight(.semibold))
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .background(.ultraThinMaterial, in: Capsule())
+ .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
+ .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ .padding(.bottom, 30)
}
+ .animation(.easeInOut(duration: 0.25), value: text)
}
- }
-}
-
-private struct QuickAppBadge: View {
- let title: String
- let accentColor: Color
-
- private var initials: String {
- let words = title.split(separator: " ")
- if let first = words.first, !first.isEmpty {
- return String(first.prefix(1)).uppercased()
+
+ private func checkPairingFileExists() {
+ let fileExists = FileManager.default.fileExists(atPath: pairingFileURL.path)
+ pairingFilePresentOnDisk = fileExists
+
+ guard fileExists else {
+ pairingFileExists = false
+ lastValidatedPairingSignature = nil
+ isValidatingPairingFile = false
+ return
+ }
+
+ let signature = pairingFileSignature(for: pairingFileURL)
+
+ guard needsValidation(for: signature) else { return }
+ guard !isValidatingPairingFile else { return }
+
+ isValidatingPairingFile = true
+
+ DispatchQueue.global(qos: .utility).async {
+ let valid = isPairing()
+ DispatchQueue.main.async {
+ pairingFileExists = valid
+ lastValidatedPairingSignature = signature
+ isValidatingPairingFile = false
+ }
+ }
+ }
+
+ private func needsValidation(for signature: PairingFileSignature) -> Bool {
+ guard let lastSignature = lastValidatedPairingSignature else { return true }
+ return lastSignature != signature
+ }
+
+
+ private func pairingFileSignature(for url: URL) -> PairingFileSignature {
+ let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
+ let modificationDate = attributes[.modificationDate] as? Date
+ let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
+ return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue)
+ }
+ private func refreshBackground() { }
+
+ private 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 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 func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
+ if let assigned = assignedScript(for: bundleID) {
+ return assigned
+ }
+ return autoScript(for: bundleID)
+ }
+
+ private 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 func syncFavoriteAppNamesWithCache() {
+ guard let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") else { return }
+ let favorites = sharedDefaults.stringArray(forKey: "favoriteApps") ?? []
+ guard !favorites.isEmpty else { return }
+
+ var storedNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:]
+ var changed = false
+
+ for bundle in favorites {
+ guard let rawName = cachedAppNames[bundle], !rawName.isEmpty else { continue }
+ let display = shortDisplayName(from: rawName)
+ if storedNames[bundle] != display {
+ storedNames[bundle] = display
+ changed = true
+ }
+ }
+
+ if changed {
+ sharedDefaults.set(storedNames, forKey: "favoriteAppNames")
+ WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
+ }
+ }
+
+ private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
+ switch appName {
+ case "maciOS":
+ return ("maciOS", "maciOS.js")
+ case "Amethyst":
+ return ("Amethyst", "Amethyst.js")
+ case "Geode":
+ return ("Geode", "Geode.js")
+ case "MeloNX":
+ return ("MeloNX", "MeloNX.js")
+ case "Manic EMU":
+ return ("manic", "manic.js")
+ case "UTM", "DolphiniOS":
+ return ("UTM-Dolphin", "UTM-Dolphin.js")
+ default:
+ return nil
+ }
+ }
+
+ private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
+ return { pid, debugProxyHandle, remoteServerHandle, semaphore in
+ jsModel = RunJSViewModel(pid: Int(pid),
+ debugProxy: debugProxyHandle,
+ remoteServer: remoteServerHandle,
+ semaphore: semaphore)
+ scriptViewShow = true
+ DispatchQueue.global(qos: .background).async {
+ do { try jsModel?.runScript(data: script, name: name) }
+ catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) }
+ }
+ }
+ }
+
+ private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
+ isProcessing = true
+ LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
+
+ DispatchQueue.global(qos: .background).async {
+ var scriptData = scriptData
+ var scriptName = scriptName
+ if scriptData == nil,
+ let bundleID,
+ let preferred = preferredScript(for: bundleID) {
+ scriptName = preferred.name
+ scriptData = preferred.data
+ }
+
+ var callback: DebugAppCallback? = nil
+ if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
+ callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script")
+ if triggeredByURLScheme { usleep(500000) }
+ pipRequired = true
+ } else {
+ pipRequired = false
+ }
+
+ let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } }
+ var success: Bool
+ if let pid {
+ success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
+ if success { DispatchQueue.main.async { addRecentPID(pid) } }
+ } else if let bundleID {
+ success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
+ } else {
+ DispatchQueue.main.async {
+ showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
+ }
+ success = false
+ }
+
+ if success {
+ DispatchQueue.main.async {
+ LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))")
+ }
+ }
+ isProcessing = false
+ pipRequired = false
}
- return String(title.prefix(1)).uppercased()
- }
-
- var body: some View {
- Text(initials)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .frame(width: 36, height: 36)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(accentColor.opacity(0.16))
- )
- .foregroundStyle(accentColor)
}
-}
-
-private struct StatusLightView: View {
- let light: StatusLightData
-
- var body: some View {
- VStack(spacing: 6) {
- ZStack {
- Circle()
- .fill(light.status.tint.opacity(0.18))
- .frame(width: 48, height: 48)
- Image(systemName: light.icon)
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(light.status.tint)
+
+ private func launchSystemApp(item: SystemPinnedItem) {
+ guard !launchingSystemApps.contains(item.bundleID) else { return }
+ launchingSystemApps.insert(item.bundleID)
+ HapticFeedbackHelper.trigger()
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ let success = JITEnableContext.shared.launchAppWithoutDebug(item.bundleID, logger: nil)
+
+ DispatchQueue.main.async {
+ launchingSystemApps.remove(item.bundleID)
+ if success {
+ LogManager.shared.addInfoLog("Launch request sent for \(item.bundleID)")
+ systemLaunchMessage = String(format: "Launch requested: %@".localized, item.displayName)
+ } else {
+ LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)")
+ systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName)
+ }
+ scheduleSystemToastDismiss()
+ }
}
- .overlay(
- Circle()
- .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
- .frame(width: 48, height: 48)
- )
- .overlay(alignment: .bottomTrailing) {
- Image(systemName: light.status.iconName)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(light.status.symbolColor)
- .padding(4)
+ }
+
+ private func removePinnedSystemApp(bundleID: String) {
+ Haptics.light()
+ pinnedSystemApps.removeAll { $0 == bundleID }
+ pinnedSystemAppNames.removeValue(forKey: bundleID)
+ persistPinnedSystemApps()
+ }
+
+ private func scheduleSystemToastDismiss() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ if systemLaunchMessage != nil {
+ withAnimation {
+ systemLaunchMessage = nil
+ }
+ }
+ }
+ }
+
+ private func persistPinnedSystemApps() {
+ if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") {
+ sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
+ sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
+ }
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+
+ private func addRecentPID(_ pid: Int) {
+ var list = recentPIDs.filter { $0 != pid }
+ list.insert(pid, at: 0)
+ if list.count > 8 { list = Array(list.prefix(8)) }
+ recentPIDs = list
+ }
+
+ func base64URLToBase64(_ base64url: String) -> String {
+ var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ let pad = 4 - (base64.count % 4)
+ if pad < 4 { base64 += String(repeating: "=", count: pad) }
+ return base64
+ }
+
+ private struct StatusDot: View {
+ var color: Color
+ @Environment(\.colorScheme) private var colorScheme
+ var body: some View {
+ ZStack {
+ Circle().fill(color.opacity(0.25)).frame(width: 20, height: 20)
+ Circle().fill(color).frame(width: 12, height: 12)
+ .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
+ }
+ .overlay(
+ Circle().stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
+ )
+ }
+ }
+
+ private struct StatusGlyph: View {
+ let icon: String
+ let tint: Color
+ var size: CGFloat = 48
+ var iconSize: CGFloat = 22
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(tint.opacity(0.18))
+ .frame(width: size, height: size)
+
+ Image(systemName: icon)
+ .font(.system(size: iconSize, weight: .semibold, design: .rounded))
+ .foregroundStyle(tint)
+ }
+ .overlay(
+ Circle()
+ .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
+ )
+ }
+ }
+
+ private struct QuickConnectRow: View {
+ let item: QuickConnectItem
+ let accentColor: Color
+ let isEnabled: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 14) {
+ QuickAppBadge(title: item.displayName, accentColor: accentColor)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.displayName)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+
+ Text(item.bundleID)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .textSelection(.enabled)
+ }
+
+ Spacer(minLength: 0)
+
+ Image(systemName: "bolt.horizontal.circle.fill")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(isEnabled ? accentColor : Color.secondary)
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
.background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(!isEnabled)
+ .opacity(isEnabled ? 1 : 0.55)
+ }
+ }
+
+ private struct SystemPinnedRow: View {
+ let item: SystemPinnedItem
+ let accentColor: Color
+ let isLaunching: Bool
+ var action: () -> Void
+ var onRemove: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 14) {
+ QuickAppBadge(title: item.displayName, accentColor: accentColor)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.displayName)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+
+ Text(item.bundleID)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .textSelection(.enabled)
+ }
+
+ Spacer(minLength: 0)
+
+ if isLaunching {
+ ProgressView()
+ .controlSize(.small)
+ .tint(accentColor)
+ } else {
+ Image(systemName: "play.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(accentColor)
+ }
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(isLaunching)
+ .contextMenu {
+ Button("Remove from Home".localized, systemImage: "star.slash") {
+ onRemove()
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ onRemove()
+ } label: {
+ Label("Remove".localized, systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ private struct QuickAppBadge: View {
+ let title: String
+ let accentColor: Color
+
+ private var initials: String {
+ let words = title.split(separator: " ")
+ if let first = words.first, !first.isEmpty {
+ return String(first.prefix(1)).uppercased()
+ }
+ return String(title.prefix(1)).uppercased()
+ }
+
+ var body: some View {
+ Text(initials)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .frame(width: 36, height: 36)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(accentColor.opacity(0.16))
+ )
+ .foregroundStyle(accentColor)
+ }
+ }
+
+ private struct StatusLightView: View {
+ let light: StatusLightData
+
+ var body: some View {
+ let tint = light.tintOverride ?? light.status.tint
+ VStack(spacing: 6) {
+ ZStack {
Circle()
- .fill(Color(.systemBackground))
- .shadow(color: .black.opacity(0.12), radius: 1.5, x: 0, y: 1)
+ .fill(tint.opacity(0.18))
+ .frame(width: 48, height: 48)
+ Image(systemName: light.icon)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(tint)
+ }
+ .overlay(
+ Circle()
+ .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
+ .frame(width: 48, height: 48)
)
- .offset(x: 6, y: 6)
+ .overlay(alignment: .bottomTrailing) {
+ Image(systemName: light.indicatorIconName ?? light.status.iconName)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(light.indicatorColor ?? light.status.symbolColor)
+ .padding(4)
+ .background(
+ Circle()
+ .fill(Color(.systemBackground))
+ .shadow(color: .black.opacity(0.12), radius: 1.5, x: 0, y: 1)
+ )
+ .offset(x: 6, y: 6)
+ }
+
+ VStack(spacing: 0) {
+ Text(light.title)
+ .font(.caption2.weight(.semibold))
+ Text(light.detail)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(width: 80)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(light.title) status")
+ .accessibilityValue("\(light.detail). \(light.status.accessibilityDescription)")
}
-
- VStack(spacing: 0) {
- Text(light.title)
- .font(.caption2.weight(.semibold))
- Text(light.detail)
- .font(.caption2)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
+ }
+
+ private struct StatusLightData: Identifiable {
+ let id = UUID()
+ let type: StatusLightType
+ let title: String
+ let icon: String
+ let status: StartupIndicatorStatus
+ let detail: String
+ let action: (() -> Void)?
+ let isEnabled: Bool
+ let indicatorIconName: String?
+ let indicatorColor: Color?
+ let tintOverride: Color?
+
+ init(type: StatusLightType,
+ title: String,
+ icon: String,
+ status: StartupIndicatorStatus,
+ detail: String,
+ action: (() -> Void)? = nil,
+ isEnabled: Bool = true,
+ indicatorIconName: String? = nil,
+ indicatorColor: Color? = nil,
+ tintOverride: Color? = nil) {
+ self.type = type
+ self.title = title
+ self.icon = icon
+ self.status = status
+ self.detail = detail
+ self.action = action
+ self.isEnabled = isEnabled
+ self.indicatorIconName = indicatorIconName
+ self.indicatorColor = indicatorColor
+ self.tintOverride = tintOverride
}
- .frame(width: 80)
}
- .accessibilityElement(children: .ignore)
- .accessibilityLabel("\(light.title) status")
- .accessibilityValue("\(light.detail). \(light.status.accessibilityDescription)")
- }
-}
-
-private struct StatusLightData: Identifiable {
- let id = UUID()
- let type: StatusLightType
- let title: String
- let icon: String
- let status: StartupIndicatorStatus
- let detail: String
-}
-
-private enum StatusLightType {
- case ddi
- case wifi
- case heartbeat
-}
-
-private struct PairingFileSignature: Equatable {
- let modificationDate: Date?
- let fileSize: UInt64
-}
-
-private enum ConnectionCheckState: Equatable {
- case idle
- case running
- case success
- case failure(String)
- case timeout
-}
-
-private enum StartupIndicatorStatus: Equatable {
- case idle
- case running
- case success
- case warning
- case error
-
- var iconName: String {
- switch self {
- case .success: return "checkmark.circle.fill"
- case .warning: return "exclamationmark.triangle.fill"
- case .error: return "xmark.circle.fill"
- case .idle: return "circle"
- case .running: return "clock.arrow.circlepath"
+
+ private enum StatusLightType {
+ case ddi
+ case wifi
+ case heartbeat
+ case refresh
}
- }
-
- var tint: Color {
- switch self {
- case .success: return .green
- case .warning: return .yellow
- case .error: return .red
- case .idle: return .secondary
- case .running: return .orange
+
+ private struct PairingFileSignature: Equatable {
+ let modificationDate: Date?
+ let fileSize: UInt64
}
- }
-
- var symbolColor: Color {
- switch self {
- case .success: return .green
- case .warning: return .orange
- case .error: return .red
- case .idle: return .secondary
- case .running: return .blue
+
+ private enum ConnectionCheckState: Equatable {
+ case idle
+ case running
+ case success
+ case failure(String)
+ case timeout
}
- }
-
- var accessibilityDescription: String {
- switch self {
- case .success: return "Success"
- case .warning: return "Warning"
- case .error: return "Error"
- case .idle: return "Idle"
- case .running: return "In progress"
+
+ private enum StartupIndicatorStatus: Equatable {
+ case idle
+ case running
+ case success
+ case warning
+ case error
+
+ var iconName: String {
+ switch self {
+ case .success: return "checkmark.circle.fill"
+ case .warning: return "exclamationmark.triangle.fill"
+ case .error: return "xmark.circle.fill"
+ case .idle: return "circle"
+ case .running: return "clock.arrow.circlepath"
+ }
+ }
+
+ var tint: Color {
+ switch self {
+ case .success: return .green
+ case .warning: return .yellow
+ case .error: return .red
+ case .idle: return .secondary
+ case .running: return .orange
+ }
+ }
+
+ var symbolColor: Color {
+ switch self {
+ case .success: return .green
+ case .warning: return .orange
+ case .error: return .red
+ case .idle: return .secondary
+ case .running: return .blue
+ }
+ }
+
+ var accessibilityDescription: String {
+ switch self {
+ case .success: return "Success"
+ case .warning: return "Warning"
+ case .error: return "Error"
+ case .idle: return "Idle"
+ case .running: return "In progress"
+ }
+ }
}
- }
-}
-private struct QuickConnectItem: Identifiable {
- let bundleID: String
- let displayName: String
- var id: String { bundleID }
-}
-
-private struct SystemPinnedItem: Identifiable {
- let bundleID: String
- let displayName: String
- var id: String { bundleID }
-}
-
-// MARK: - Connect-by-PID Sheet (minus/plus removed)
-
-private struct ConnectByPIDSheet: View {
- @Environment(\.dismiss) private var dismiss
- @Binding var recentPIDs: [Int]
- @State private var pidText: String = ""
- @State private var errorText: String? = nil
- @FocusState private var focused: Bool
- var onPasteCopyToast: () -> Void
- var onConnect: (Int) -> Void
-
- private var isValid: Bool {
- if let v = Int(pidText), v > 0 { return true }
- return false
- }
-
- private let capsuleHeight: CGFloat = 40
-
- var body: some View {
- NavigationView {
- ZStack {
- Color.clear.ignoresSafeArea()
-
- ScrollView {
- VStack(spacing: 20) {
- VStack(alignment: .leading, spacing: 14) {
- Text("Enter a Process ID").font(.headline).foregroundColor(.primary)
-
- TextField("e.g. 1234", text: $pidText)
- .keyboardType(.numberPad)
- .textContentType(.oneTimeCode)
- .font(.system(.title3, design: .rounded))
- .padding(12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(.ultraThinMaterial)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
- )
- .focused($focused)
- .onChange(of: pidText) { _, newVal in validate(newVal) }
-
- // Paste + Clear row
- HStack(spacing: 10) {
- CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) {
- if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
- let v = Int(n), v > 0 {
- pidText = String(v)
- validate(pidText)
- onPasteCopyToast()
- } else {
- errorText = "No valid PID on the clipboard."
- UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ private struct QuickConnectItem: Identifiable {
+ let bundleID: String
+ let displayName: String
+ var id: String { bundleID }
+ }
+
+ private struct SystemPinnedItem: Identifiable {
+ let bundleID: String
+ let displayName: String
+ var id: String { bundleID }
+ }
+
+ // MARK: - Connect-by-PID Sheet (minus/plus removed)
+
+ private struct ConnectByPIDSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Binding var recentPIDs: [Int]
+ @State private var pidText: String = ""
+ @State private var errorText: String? = nil
+ @FocusState private var focused: Bool
+ var onPasteCopyToast: () -> Void
+ var onConnect: (Int) -> Void
+
+ private var isValid: Bool {
+ if let v = Int(pidText), v > 0 { return true }
+ return false
+ }
+
+ private let capsuleHeight: CGFloat = 40
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ Color.clear.ignoresSafeArea()
+
+ ScrollView {
+ VStack(spacing: 20) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Enter a Process ID").font(.headline).foregroundColor(.primary)
+
+ TextField("e.g. 1234", text: $pidText)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ .font(.system(.title3, design: .rounded))
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(.ultraThinMaterial)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
+ )
+ .focused($focused)
+ .onChange(of: pidText) { _, newVal in validate(newVal) }
+
+ // Paste + Clear row
+ HStack(spacing: 10) {
+ CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) {
+ if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
+ let v = Int(n), v > 0 {
+ pidText = String(v)
+ validate(pidText)
+ onPasteCopyToast()
+ } else {
+ errorText = "No valid PID on the clipboard."
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ }
+
+ CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) {
+ pidText = ""
+ errorText = nil
+ }
}
- }
-
- CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) {
- pidText = ""
- errorText = nil
- }
- }
-
-
- if let errorText {
- HStack(spacing: 6) {
- Image(systemName: "exclamationmark.triangle.fill").font(.footnote)
- Text(errorText).font(.footnote)
- }
- .foregroundColor(.orange)
- .transition(.opacity)
- }
-
- if !recentPIDs.isEmpty {
- VStack(alignment: .leading, spacing: 8) {
- Text("Recents")
- .font(.subheadline.weight(.semibold))
- .foregroundColor(.secondary)
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(recentPIDs, id: \.self) { pid in
- Button {
- pidText = String(pid); validate(pidText)
- } label: {
- Text("#\(pid)")
- .font(.footnote.weight(.semibold))
- .padding(.vertical, 6)
- .padding(.horizontal, 10)
- .background(
- Capsule(style: .continuous)
- .fill(Color(UIColor.tertiarySystemBackground))
- )
- }
- .contextMenu {
- Button(role: .destructive) {
- removeRecent(pid)
- } label: { Label("Remove", systemImage: "trash") }
+
+
+ if let errorText {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill").font(.footnote)
+ Text(errorText).font(.footnote)
+ }
+ .foregroundColor(.orange)
+ .transition(.opacity)
+ }
+
+ if !recentPIDs.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Recents")
+ .font(.subheadline.weight(.semibold))
+ .foregroundColor(.secondary)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(recentPIDs, id: \.self) { pid in
+ Button {
+ pidText = String(pid); validate(pidText)
+ } label: {
+ Text("#\(pid)")
+ .font(.footnote.weight(.semibold))
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color(UIColor.tertiarySystemBackground))
+ )
+ }
+ .contextMenu {
+ Button(role: .destructive) {
+ removeRecent(pid)
+ } label: { Label("Remove", systemImage: "trash") }
+ }
+ }
}
}
}
}
+
+ Button {
+ guard let pid = Int(pidText), pid > 0 else { return }
+ onConnect(pid)
+ addRecent(pid)
+ dismiss()
+ } label: {
+ HStack {
+ Image(systemName: "bolt.horizontal.circle").font(.system(size: 20))
+ Text("Connect")
+ .font(.system(.title3, design: .rounded))
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .foregroundColor(Color.accentColor.contrastText())
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
+ )
+ }
+ .disabled(!isValid)
+ .padding(.top, 8)
}
- }
-
- Button {
- guard let pid = Int(pidText), pid > 0 else { return }
- onConnect(pid)
- addRecent(pid)
- dismiss()
- } label: {
- HStack {
- Image(systemName: "bolt.horizontal.circle").font(.system(size: 20))
- Text("Connect")
- .font(.system(.title3, design: .rounded))
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 14)
- .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
- .foregroundColor(Color.accentColor.contrastText())
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
+ .padding(20)
+ .background(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .fill(.ultraThinMaterial)
+ .overlay(
+ 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)
}
- .disabled(!isValid)
- .padding(.top, 8)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 30)
}
- .padding(20)
+ }
+ .navigationTitle("Connect by PID")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } }
+ .onAppear { focused = true }
+ }
+ }
+
+ // Small glassy square icon button
+ private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: systemName)
+ .font(.headline)
+ .frame(width: 36, height: 36)
.background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(UIColor.tertiarySystemBackground))
)
- .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
- }
- .padding(.horizontal, 20)
- .padding(.vertical, 30)
}
+ .buttonStyle(.plain)
+ .contentShape(Rectangle())
}
- .navigationTitle("Connect by PID")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } }
- .onAppear { focused = true }
- }
- }
-
- // Small glassy square icon button
- private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Image(systemName: systemName)
- .font(.headline)
- .frame(width: 36, height: 36)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color(UIColor.tertiarySystemBackground))
- )
- }
- .buttonStyle(.plain)
- .contentShape(Rectangle())
- }
-
- private func validate(_ text: String) {
- if text.isEmpty { errorText = nil; return }
- if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." }
- else { errorText = nil }
- }
- private func addRecent(_ pid: Int) {
- var list = recentPIDs.filter { $0 != pid }
- list.insert(pid, at: 0)
- if list.count > 8 { list = Array(list.prefix(8)) }
- recentPIDs = list
- }
- private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } }
- private func prefillFromClipboardIfPossible() {
- if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
- let v = Int(s), v > 0 {
- pidText = String(v); errorText = nil
- }
- }
-
- @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- HStack(spacing: 6) {
- Image(systemName: systemName)
- Text(title).font(.subheadline.weight(.semibold))
+
+ private func validate(_ text: String) {
+ if text.isEmpty { errorText = nil; return }
+ if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." }
+ else { errorText = nil }
+ }
+ private func addRecent(_ pid: Int) {
+ var list = recentPIDs.filter { $0 != pid }
+ list.insert(pid, at: 0)
+ if list.count > 8 { list = Array(list.prefix(8)) }
+ recentPIDs = list
+ }
+ private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } }
+ private func prefillFromClipboardIfPossible() {
+ if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
+ let v = Int(s), v > 0 {
+ pidText = String(v); errorText = nil
+ }
+ }
+
+ @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack(spacing: 6) {
+ Image(systemName: systemName)
+ Text(title).font(.subheadline.weight(.semibold))
+ }
+ .frame(height: height) // enforce uniform height
+ .padding(.horizontal, 12)
+ .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
+ }
+ .buttonStyle(.plain)
+ .contentShape(Rectangle())
}
- .frame(height: height) // enforce uniform height
- .padding(.horizontal, 12)
- .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
}
- .buttonStyle(.plain)
- .contentShape(Rectangle())
+
}
-}
-
-}
// MARK: - TXM detection
@@ -2003,20 +2145,24 @@ public extension ProcessInfo {
if isTXMOverridden {
return true
}
-
- return {
- if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
- let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
- return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
- } else {
- return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map {
- access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
- }) ?? false
- }
- }()
+ if DeviceLibraryStore.shared.isUsingExternalDevice {
+ return DeviceLibraryStore.shared.activeDevice?.isTXM ?? false
+ }
+ return ProcessInfo.detectLocalTXM()
}
-
+
var isTXMOverridden: Bool {
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
}
+
+ private static func detectLocalTXM() -> Bool {
+ if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
+ let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
+ return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
+ } else {
+ return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map {
+ access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
+ }) ?? false
+ }
+ }
}
diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift
index 7a356995..13cdcfba 100644
--- a/StikJIT/Views/InstalledAppsListView.swift
+++ b/StikJIT/Views/InstalledAppsListView.swift
@@ -783,6 +783,7 @@ struct AppButton: View {
let performanceMode: Bool
@State private var showScriptPicker = false
+ @State private var assignedScriptName: String?
@StateObject private var iconLoader: IconLoader
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
@@ -804,6 +805,7 @@ struct AppButton: View {
self.sharedDefaults = sharedDefaults
self.performanceMode = performanceMode
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
+ _assignedScriptName = State(initialValue: AppButton.currentAssignment(for: bundleID))
}
var body: some View {
@@ -857,6 +859,13 @@ struct AppButton: View {
Button { showScriptPicker = true } label: {
Label("Assign Script", systemImage: "chevron.left.slash.chevron.right")
}
+ if assignedScriptName != nil {
+ Button {
+ resetScriptAssignment()
+ } label: {
+ Label("Reset Script", systemImage: "arrow.uturn.left")
+ }
+ }
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -954,14 +963,26 @@ struct AppButton: View {
private func assignScript(_ url: URL?) {
var mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] ?? [:]
if let url {
- mapping[bundleID] = url.lastPathComponent
+ let filename = url.lastPathComponent
+ mapping[bundleID] = filename
+ assignedScriptName = filename
} else {
mapping.removeValue(forKey: bundleID)
+ assignedScriptName = nil
}
UserDefaults.standard.set(mapping, forKey: "BundleScriptMap")
Haptics.light()
}
+ private func resetScriptAssignment() {
+ assignScript(nil)
+ }
+
+ private static func currentAssignment(for bundleID: String) -> String? {
+ let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String]
+ return mapping?[bundleID]
+ }
+
private func persistIfChanged() {
var touched = false
let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift
index 0951a0ba..dbb7a9f2 100644
--- a/StikJIT/Views/MainTabView.swift
+++ b/StikJIT/Views/MainTabView.swift
@@ -49,19 +49,27 @@ struct MainTabView: View {
#endif
}
- private let configurableTabs: [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: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) },
- TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) },
- TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
- TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) }
- ]
+ 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: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) },
+ TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }
+ ]
+ if FeatureFlags.showBetaTabs {
+ 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" || (FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild)
+ descriptor.id != "location" || (!isAppStoreBuild && FeatureFlags.isLocationSpoofingEnabled && FeatureFlags.showBetaTabs)
}
}
diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift
index 82823bdc..0af5d6dd 100644
--- a/StikJIT/Views/MapSelectionView.swift
+++ b/StikJIT/Views/MapSelectionView.swift
@@ -8,6 +8,7 @@
import SwiftUI
import MapKit
import UIKit
+import Pipify
struct MapSelectionView: UIViewRepresentable {
@Binding var coordinate: CLLocationCoordinate2D?
@@ -64,6 +65,12 @@ struct MapSelectionView: UIViewRepresentable {
}
}
+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
@State private var coordinate: CLLocationCoordinate2D?
@@ -75,6 +82,9 @@ struct LocationSimulationView: View {
@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
@@ -136,8 +146,20 @@ struct LocationSimulationView: View {
.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 {
@@ -290,11 +312,13 @@ struct LocationSimulationView: View {
showKeepOpenAlert = true
beginBackgroundTask()
startResendLoop()
+ recordPiPEvent(status: "Simulating…", coordinate: coord)
} else {
statusMessage = "Simulation failed (code \(code))."
statusIsError = true
stopResendLoop()
endBackgroundTask()
+ dismissPiPSession()
}
}
@@ -306,6 +330,14 @@ struct LocationSimulationView: View {
showKeepOpenAlert = false
stopResendLoop()
endBackgroundTask()
+ if code == 0 {
+ recordPiPEvent(status: "Simulation cleared", coordinate: nil)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ dismissPiPSession()
+ }
+ } else {
+ dismissPiPSession()
+ }
}
private func beginBackgroundTask() {
@@ -324,8 +356,12 @@ struct LocationSimulationView: View {
private func startResendLoop() {
resendTimer?.invalidate()
resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
- guard pairingExists, let coord = coordinate else { return }
- _ = simulate_location(deviceIP, coord.latitude, coord.longitude, pairingFilePath)
+ 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)
}
}
@@ -333,6 +369,23 @@ 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 {
@@ -341,3 +394,52 @@ private struct SearchResult: Identifiable {
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))
+ )
+ }
+}
diff --git a/StikJIT/Views/ProfileView.swift b/StikJIT/Views/ProfileView.swift
index 16a287b1..699e08e6 100644
--- a/StikJIT/Views/ProfileView.swift
+++ b/StikJIT/Views/ProfileView.swift
@@ -336,20 +336,10 @@ struct ProfileView: View {
private func profileActionButton(icon: String, color: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
- .font(.system(size: 13, weight: .bold))
- .foregroundStyle(color.contrastText())
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(color.opacity(0.9))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.2), lineWidth: 1)
- )
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(color)
}
- .buttonStyle(.plain)
+ .buttonStyle(.borderless)
}
private func removeProfilePrompt(entry: Profile) {
diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift
index 41686f62..ad344aa9 100644
--- a/StikJIT/Views/SettingsView.swift
+++ b/StikJIT/Views/SettingsView.swift
@@ -10,11 +10,10 @@ import UIKit
struct SettingsView: View {
@AppStorage("username") private var username = "User"
@AppStorage("selectedAppIcon") private var selectedAppIcon: String = "AppIcon"
- @AppStorage("useDefaultScript") private var useDefaultScript = false
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
@AppStorage("enableAdvancedBetaOptions") private var enableAdvancedBetaOptions = false
@AppStorage("enableTesting") private var enableTesting = false
- @AppStorage(UserDefaults.Keys.enableContinuedProcessing) private var enableContinuedProcessing = false
+ @AppStorage("enablePiP") private var enablePiP = false
@AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@@ -91,11 +90,14 @@ struct SettingsView: View {
TabOption(id: "console", title: "Console", detail: "Live device logs", icon: "terminal", isBeta: false),
TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false),
TabOption(id: "profiles", title: "Profiles", detail: "Install/remove profiles", icon: "magazine.fill", isBeta: false),
- TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true),
TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false)
]
- if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild {
- options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true))
+ if FeatureFlags.showBetaTabs {
+ options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true))
+ options.append(TabOption(id: "devicelibrary", title: "Devices", detail: "Manage external devices", icon: "list.bullet.rectangle", isBeta: true))
+ if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild {
+ options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true))
+ }
}
return options
}
@@ -223,7 +225,9 @@ struct SettingsView: View {
}
RunLoop.current.add(progressTimer, forMode: .common)
- startHeartbeatInBackground()
+ DispatchQueue.main.async {
+ startHeartbeatInBackground(requireVPNConnection: false)
+ }
} catch {
print("Error copying file: \(error)")
@@ -265,7 +269,7 @@ struct SettingsView: View {
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
- Text("StikDebug")
+ Text("StikDebug 26")
.font(.title2.weight(.semibold))
.foregroundColor(.primary)
}
@@ -403,7 +407,6 @@ struct SettingsView: View {
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
}
-
if showPairingFileMessage && !isImportingFile {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
@@ -426,9 +429,8 @@ struct SettingsView: View {
.font(.headline)
.foregroundColor(.primary)
- Toggle("Run Default Script After Connecting", isOn: $useDefaultScript)
+ Toggle("Picture in Picture", isOn: $enablePiP)
.tint(accentColor)
- continuedProcessingToggle
Toggle(isOn: $overrideTXMDetection) {
VStack(alignment: .leading, spacing: 2) {
Text("Always Run Scripts")
@@ -443,8 +445,7 @@ struct SettingsView: View {
}
.onChange(of: enableAdvancedOptions) { _, newValue in
if !newValue {
- useDefaultScript = false
- enableContinuedProcessing = false
+ enablePiP = false
enableAdvancedBetaOptions = false
enableTesting = false
}
@@ -454,13 +455,6 @@ struct SettingsView: View {
enableTesting = false
}
}
- .onChange(of: enableContinuedProcessing) { _, newValue in
- if newValue {
- ContinuedProcessingManager.shared.configureIfNeeded()
- } else {
- ContinuedProcessingManager.shared.cancelPendingTasks()
- }
- }
}
}
@@ -516,24 +510,6 @@ struct SettingsView: View {
}
}
- private var continuedProcessingToggle: some View {
- Group {
- if ContinuedProcessingManager.shared.isSupported {
- Toggle("Allow Continued Processing", isOn: $enableContinuedProcessing)
- .tint(accentColor)
- } else {
- VStack(alignment: .leading, spacing: 4) {
- Toggle("Allow Continued Processing", isOn: .constant(false))
- .tint(accentColor)
- .disabled(true)
- Text("Requires iOS 26 or later.")
- .font(.caption)
- .foregroundColor(.secondary)
- }
- }
- }
- }
-
private var helpCard: some View {
glassCard {
VStack(alignment: .leading, spacing: 14) {
@@ -573,14 +549,6 @@ struct SettingsView: View {
.padding(.vertical, 8)
}
- HStack(alignment: .center, spacing: 8) {
- Image(systemName: "shield.slash")
- .font(.system(size: 18))
- .foregroundColor(.primary.opacity(0.8))
- Text("You can turn off the VPN in the Settings app.")
- .foregroundColor(.secondary)
- }
- .padding(.top, 4)
}
}
}
diff --git a/StikJIT/idevice/heartbeat.m b/StikJIT/idevice/heartbeat.m
index fafe403f..6e22cd2b 100644
--- a/StikJIT/idevice/heartbeat.m
+++ b/StikJIT/idevice/heartbeat.m
@@ -10,6 +10,7 @@
#include
#include
#include "heartbeat.h"
+@import Foundation;
bool isHeartbeat = false;
@@ -25,7 +26,11 @@ void startHeartbeat(IdevicePairingFile* pairing_file, IdeviceProviderHandle** pr
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(LOCKDOWN_PORT);
- inet_pton(AF_INET, "10.7.0.2", &addr.sin_addr);
+ NSString *ipOverride = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"];
+ if (ipOverride.length == 0) {
+ ipOverride = @"10.7.0.2";
+ }
+ inet_pton(AF_INET, ipOverride.UTF8String, &addr.sin_addr);
IdeviceProviderHandle* newProvider = 0;
IdeviceFfiError* err = idevice_tcp_provider_new((struct sockaddr *)&addr, pairing_file,
diff --git a/StikJIT/idevice/jit.c b/StikJIT/idevice/jit.c
index 984b9691..d725d271 100644
--- a/StikJIT/idevice/jit.c
+++ b/StikJIT/idevice/jit.c
@@ -18,7 +18,11 @@
#include "jit.h"
-void runDebugServerCommand(int pid, DebugProxyHandle* debug_proxy, LogFuncC logger, DebugAppCallback callback) {
+void runDebugServerCommand(int pid,
+ DebugProxyHandle* debug_proxy,
+ RemoteServerHandle* remote_server,
+ LogFuncC logger,
+ DebugAppCallback callback) {
// enable QStartNoAckMode
char *disableResponse = NULL;
debug_proxy_send_ack(debug_proxy);
@@ -32,7 +36,7 @@ void runDebugServerCommand(int pid, DebugProxyHandle* debug_proxy, LogFuncC logg
if(callback) {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
- callback(pid, debug_proxy, semaphore);
+ 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);
@@ -193,7 +197,7 @@ int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFun
return 1;
}
- runDebugServerCommand((int)pid, debug_proxy, logger, callback);
+ runDebugServerCommand((int)pid, debug_proxy, remote_server, logger, callback);
/*****************************************************************
* Cleanup
@@ -296,7 +300,7 @@ int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger,
}
- runDebugServerCommand(pid, debug_proxy, logger, callback);
+ runDebugServerCommand(pid, debug_proxy, remote_server, logger, callback);
/*****************************************************************
* Cleanup
diff --git a/StikJIT/idevice/jit.h b/StikJIT/idevice/jit.h
index 87f8ec40..d745c6db 100644
--- a/StikJIT/idevice/jit.h
+++ b/StikJIT/idevice/jit.h
@@ -11,7 +11,10 @@
#include "idevice.h"
typedef void (^LogFuncC)(const char* message, ...);
-typedef void (^DebugAppCallback)(int pid, struct DebugProxyHandle* debug_proxy, dispatch_semaphore_t semaphore);
+typedef void (^DebugAppCallback)(int pid,
+ struct DebugProxyHandle* debug_proxy,
+ struct RemoteServerHandle* remote_server,
+ dispatch_semaphore_t semaphore);
int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger, DebugAppCallback callback);
int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, DebugAppCallback callback);
int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFuncC logger);
diff --git a/StikJIT/idevice/libidevice_ffi.a b/StikJIT/idevice/libidevice_ffi.a
index e42fb8aa..86e72314 100644
Binary files a/StikJIT/idevice/libidevice_ffi.a and b/StikJIT/idevice/libidevice_ffi.a differ
diff --git a/StikJIT/idevice/ls.c b/StikJIT/idevice/ls.c
index 70c67ba2..d3fc06cb 100644
--- a/StikJIT/idevice/ls.c
+++ b/StikJIT/idevice/ls.c
@@ -38,6 +38,15 @@ int simulate_location(const char *device_ip,
{
idevice_init_logger(Debug, Disabled, NULL);
IdeviceFfiError *err = NULL;
+
+ if (g_location_sim) {
+ if ((err = location_simulation_set(g_location_sim, latitude, longitude))) {
+ idevice_error_free(err);
+ cleanup_on_error();
+ } else {
+ return IPA_OK;
+ }
+ }
struct sockaddr_in addr = { .sin_family = AF_INET,
.sin_port = htons(LOCKDOWN_PORT) };
@@ -45,6 +54,11 @@ int simulate_location(const char *device_ip,
return IPA_ERR_INVALID_IP;
}
+ if (g_pairing) {
+ idevice_pairing_file_free(g_pairing);
+ g_pairing = NULL;
+ }
+
if ((err = idevice_pairing_file_read(pairing_file, &g_pairing))) {
idevice_error_free(err);
return IPA_ERR_PAIRING_READ;
@@ -132,12 +146,7 @@ int clear_simulated_location(void)
if (!g_location_sim) return IPA_ERR_LOCATION_CLEAR;
err = location_simulation_clear(g_location_sim);
- location_simulation_free(g_location_sim);
- g_location_sim = NULL;
+ cleanup_on_error();
- if (g_remote_server) { remote_server_free(g_remote_server); g_remote_server = NULL; }
- if (g_handshake) { rsd_handshake_free(g_handshake); g_handshake = NULL; }
- if (g_adapter) { adapter_free(g_adapter); g_adapter = NULL; }
-
return err ? IPA_ERR_LOCATION_CLEAR : IPA_OK;
}
diff --git a/StikJIT/idevice/profiles.h b/StikJIT/idevice/profiles.h
index 3bd5dd08..00f2e9ff 100644
--- a/StikJIT/idevice/profiles.h
+++ b/StikJIT/idevice/profiles.h
@@ -9,9 +9,9 @@
#define PROFILES_H
#include "idevice.h"
#include
-NSArray* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error);
-bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error);
-bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error);
+NSArray* _Nullable fetchAppProfiles(IdeviceProviderHandle* _Nonnull provider, NSError* _Nullable * _Nullable error);
+bool removeProfile(IdeviceProviderHandle* _Nonnull provider, NSString* _Nonnull uuid, NSError* _Nullable * _Nullable error);
+bool addProfile(IdeviceProviderHandle* _Nonnull provider, NSData* _Nonnull profile, NSError* _Nullable * _Nullable error);
@interface CMSDecoderHelper : NSObject
// Decode CMS/PKCS7 data and return decoded payload and any embedded certs
diff --git a/StikJIT/idevice/profiles.m b/StikJIT/idevice/profiles.m
index 53b298ed..0ba81fc4 100644
--- a/StikJIT/idevice/profiles.m
+++ b/StikJIT/idevice/profiles.m
@@ -6,7 +6,6 @@
//
#include "profiles.h"
@import Foundation;
-@import Security;
NSError* makeError(int code, NSString* msg) {
return [NSError errorWithDomain:@"profiles" code:code userInfo:@{NSLocalizedDescriptionKey: msg}];
@@ -91,87 +90,57 @@ bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** erro
return true;
}
-typedef CFTypeRef CMSDecoderRef;
-OSStatus CMSDecoderCreate(CMSDecoderRef * cmsDecoderOut);
-OSStatus CMSDecoderUpdateMessage(CMSDecoderRef cmsDecoder, const void * msgBytes, size_t msgBytesLen);
-OSStatus CMSDecoderFinalizeMessage(CMSDecoderRef cmsDecoder);
-OSStatus CMSDecoderCopyContent(CMSDecoderRef cmsDecoder, CFDataRef * contentOut);
-OSStatus CMSDecoderCopyAllCerts(CMSDecoderRef cmsDecoder, CFArrayRef * certsOut);
-
-
-// Helper to convert OSStatus -> NSError
-static NSError *NSErrorFromOSStatus(OSStatus status, NSString *message) {
- if (status == errSecSuccess) return nil;
- NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: message ?: @"Security error",
- @"OSStatus" : @(status) };
- return [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:userInfo];
-}
-
-
@implementation CMSDecoderHelper
+ (NSData*)decodeCMSData:(NSData *)cmsData
// outCerts:(NSArray * _Nullable * _Nullable)outCerts
error:(NSError * _Nullable * _Nullable)error
{
- if (!cmsData) {
- if (error) *error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSURLErrorBadURL userInfo:@{NSLocalizedDescriptionKey: @"cmsData is nil"}];
+ if (!cmsData || cmsData.length == 0) {
+ if (error) {
+ *error = [NSError errorWithDomain:NSCocoaErrorDomain
+ code:NSURLErrorBadURL
+ userInfo:@{NSLocalizedDescriptionKey: @"Invalid or empty CMS payload"}];
+ }
return nil;
}
- CMSDecoderRef decoder = NULL;
- OSStatus status = CMSDecoderCreate(&decoder);
- if (status != errSecSuccess || decoder == NULL) {
- if (error) *error = NSErrorFromOSStatus(status, @"Failed to create CMS decoder");
- return nil;
+ NSData *xmlStart = [@"" 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];
+ }
+ }
+ }
}
- // Feed data to decoder
- status = CMSDecoderUpdateMessage(decoder, cmsData.bytes, cmsData.length);
- if (status != errSecSuccess) {
- if (error) *error = NSErrorFromOSStatus(status, @"Failed to update CMS decoder with message bytes");
- CFRelease(decoder);
- return nil;
+ 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];
+ }
}
- // Finalize (parse) the message
- status = CMSDecoderFinalizeMessage(decoder);
- if (status != errSecSuccess) {
- if (error) *error = NSErrorFromOSStatus(status, @"Failed to finalize CMS message");
- CFRelease(decoder);
- return nil;
- }
-
- // Extract the content (the inner payload). This may be NULL if content is detached.
- CFDataRef contentData = NULL;
- status = CMSDecoderCopyContent(decoder, &contentData);
- if (status != errSecSuccess && status != errSecItemNotFound) {
- // errSecItemNotFound could mean no content (detached signature)
- if (error) *error = NSErrorFromOSStatus(status, @"Failed to copy CMS content");
- if (contentData) CFRelease(contentData);
- CFRelease(decoder);
- return nil;
+ if (error) {
+ *error = [NSError errorWithDomain:NSCocoaErrorDomain
+ code:NSFileReadUnknownError
+ userInfo:@{NSLocalizedDescriptionKey: @"Unable to extract plist from CMS payload"}];
}
-
-// // Extract embedded certificates (if any)
-// CFArrayRef certsArray = NULL;
-// status = CMSDecoderCopyAllCerts(decoder, &certsArray);
-// if (status == errSecSuccess && certsArray) {
-// // certsArray contains SecCertificateRef items
-// NSArray *certs = (__bridge_transfer NSArray *)certsArray;
-// if (outCerts) *outCerts = certs;
-// } else {
-// // no certs or error
-// if (status != errSecSuccess && status != errSecItemNotFound) {
-// if (error) *error = NSErrorFromOSStatus(status, @"Failed to copy embedded certificates (if any)");
-// CFRelease(decoder);
-// return NO;
-// }
-// if (outCerts) *outCerts = @[]; // empty
-// }
-
- CFRelease(decoder);
- return (__bridge NSData *)(contentData);
+ return nil;
}
@end
diff --git a/StikJIT/idevice/something.c b/StikJIT/idevice/something.c
index 3ee5da48..b71382f9 100644
--- a/StikJIT/idevice/something.c
+++ b/StikJIT/idevice/something.c
@@ -59,7 +59,7 @@ int install_ipa(const char *ip,
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_ERR_INVALID_IP;
+ return IPA_INSTALLER_ERR_INVALID_IP;
}
IdevicePairingFile *pairing_file = NULL;
@@ -69,7 +69,7 @@ int install_ipa(const char *ip,
fprintf(stderr, "Pairing file read failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
- return IPA_ERR_PAIRING_READ;
+ return IPA_INSTALLER_ERR_PAIRING_READ;
}
IdeviceProviderHandle *provider = NULL;
@@ -82,7 +82,7 @@ int install_ipa(const char *ip,
err->code, err->message);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_PROVIDER_CREATE;
+ return IPA_INSTALLER_ERR_PROVIDER_CREATE;
}
AfcClientHandle *afc = NULL;
@@ -93,7 +93,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_CONNECT;
+ return IPA_INSTALLER_ERR_AFC_CONNECT;
}
uint8_t *ipa_data = NULL;
@@ -103,7 +103,7 @@ int install_ipa(const char *ip,
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
- return IPA_ERR_IPA_READ;
+ return IPA_INSTALLER_ERR_IPA_READ;
}
const char *slash = strrchr(ipa_path, '/');
@@ -121,7 +121,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_OPEN;
+ return IPA_INSTALLER_ERR_AFC_OPEN;
}
err = afc_file_write(remote, ipa_data, ipa_len);
@@ -134,7 +134,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_WRITE;
+ return IPA_INSTALLER_ERR_AFC_WRITE;
}
InstallationProxyClientHandle *ipc = NULL;
@@ -146,7 +146,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_INSTALLPROXY_CONNECT;
+ return IPA_INSTALLER_ERR_INSTALLPROXY;
}
err = installation_proxy_install(ipc, dest, NULL);
@@ -158,9 +158,9 @@ int install_ipa(const char *ip,
fprintf(stderr, "IPA install failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
- return IPA_ERR_INSTALL;
+ return IPA_INSTALLER_ERR_INSTALL;
}
fprintf(stderr, "IPA installed successfully\n");
- return IPA_OK;
+ return IPA_INSTALLER_OK;
}
diff --git a/StikJIT/idevice/something.h b/StikJIT/idevice/something.h
index f0e00eb3..ccb1a8c5 100644
--- a/StikJIT/idevice/something.h
+++ b/StikJIT/idevice/something.h
@@ -12,16 +12,16 @@
#include
typedef enum {
- IPA_OK = 0, /* success */
- IPA_ERR_PAIRING_READ = 1, /* could not read pairing file */
- IPA_ERR_PROVIDER_CREATE = 2, /* idevice_tcp_provider_new fail */
- IPA_ERR_AFC_CONNECT = 3, /* afc_client_connect fail */
- IPA_ERR_IPA_READ = 4, /* failed to mmap or read IPA */
- IPA_ERR_AFC_OPEN = 5, /* afc_file_open fail */
- IPA_ERR_AFC_WRITE = 6, /* afc_file_write fail */
- IPA_ERR_INSTALLPROXY_CONNECT = 7, /* installation_proxy connect */
- IPA_ERR_INSTALL = 8, /* installation_proxy_install */
- IPA_ERR_INVALID_IP = 9, /* inet_pton failed */
+ 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,
diff --git a/StikJIT/it.lproj/Localizable.strings b/StikJIT/it.lproj/Localizable.strings
new file mode 100644
index 00000000..d430acfc
--- /dev/null
+++ b/StikJIT/it.lproj/Localizable.strings
@@ -0,0 +1,33 @@
+"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";
diff --git a/StikJIT/script1.js b/StikJIT/maciOS.js
similarity index 100%
rename from StikJIT/script1.js
rename to StikJIT/maciOS.js
diff --git a/StikJIT/manic.js b/StikJIT/manic.js
new file mode 100644
index 00000000..9cf9857b
--- /dev/null
+++ b/StikJIT/manic.js
@@ -0,0 +1,176 @@
+// manic.js - iOS 26 TXM JIT Support Script for Manic EMU Emulator
+// Optimized for Manic EMU using Geode.js's infinite loop mode
+// Features:
+// 1. Only handles brk #0x69 (JIT memory mapping requests)
+// 2. Keeps StikDebug connection alive indefinitely
+// 3. Supports JIT memory requests for multiple code blocks
+
+function littleEndianHexStringToNumber(hexStr) {
+ const bytes = [];
+ for (let i = 0; i < hexStr.length; i += 2) {
+ bytes.push(parseInt(hexStr.substr(i, 2), 16));
+ }
+ let num = 0n;
+ for (let i = 4; i >= 0; i--) {
+ num = (num << 8n) | BigInt(bytes[i]);
+ }
+ return num;
+}
+
+function numberToLittleEndianHexString(num) {
+ const bytes = [];
+ for (let i = 0; i < 5; i++) {
+ bytes.push(Number(num & 0xFFn));
+ num >>= 8n;
+ }
+ while (bytes.length < 8) {
+ bytes.push(0);
+ }
+ return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
+}
+
+function littleEndianHexToU32(hexStr) {
+ return parseInt(hexStr.match(/../g).reverse().join(''), 16);
+}
+
+function extractBrkImmediate(u32) {
+ return (u32 >> 5) & 0xFFFF;
+}
+
+// Check if the instruction is a BRK instruction
+// BRK instruction format: 0xD4200000 | (imm16 << 5)
+// The upper 16 bits should be 0xD420
+function isBrkInstruction(u32) {
+ return (u32 >>> 16) === 0xD420;
+}
+
+// Format size into a human-readable format
+function formatSize(size) {
+ if (size >= 1024 * 1024) {
+ return `${(size / (1024 * 1024)).toFixed(2)} MB`;
+ } else if (size >= 1024) {
+ return `${(size / 1024).toFixed(2)} KB`;
+ }
+ return `${size} bytes`;
+}
+
+log(`[Manic EMU] ========================================`);
+log(`[Manic EMU] Manic EMU iOS 26 TXM JIT Support Script`);
+log(`[Manic EMU] ========================================`);
+
+let pid = get_pid();
+log(`[Manic EMU] pid = ${pid}`);
+let attachResponse = send_command(`vAttach;${pid.toString(16)}`);
+log(`[Manic EMU] attach_response = ${attachResponse}`);
+
+let validBreakpoints = 0;
+let totalBreakpoints = 0;
+let totalMemoryMapped = 0n;
+
+// Track processed memory areas for debugging.
+let mappedRegions = [];
+
+// Infinite Loop - StikDebug Must Stay Connected in TXM Mode
+// The Manic EMU emulator dynamically creates multiple code blocks, each of which needs to be marked as executable by StikDebug.
+log(`[Manic EMU] Starting infinite loop - StikDebug will stay connected`);
+log(`[Manic EMU] Waiting for JIT memory requests (brk #0x69)...`);
+
+while (true) {
+ totalBreakpoints++;
+
+ let brkResponse = send_command(`c`);
+
+ // Check Exception Types (metype)
+ // metype:6 = EXC_BREAKPOINT
+ let metypeMatch = /metype:(\d+)/.exec(brkResponse);
+ let metype = metypeMatch ? parseInt(metypeMatch[1]) : -1;
+
+ // If it's not a breakpoint exception, just continue (let the program handle it).
+ if (metype !== 6 && metype !== -1) {
+ log(`[Manic EMU] Non-breakpoint exception (metype=${metype}), continuing...`);
+ continue;
+ }
+
+ let tidMatch = /T[0-9a-f]+thread:(?[0-9a-f]+);/.exec(brkResponse);
+ let tid = tidMatch ? tidMatch.groups['tid'] : null;
+ let pcMatch = /20:(?[0-9a-f]{16});/.exec(brkResponse);
+ let pc = pcMatch ? pcMatch.groups['reg'] : null;
+ let x0Match = /00:(?[0-9a-f]{16});/.exec(brkResponse);
+ let x0 = x0Match ? x0Match.groups['reg'] : null;
+ let x1Match = /01:(?[0-9a-f]{16});/.exec(brkResponse);
+ let x1 = x1Match ? x1Match.groups['reg'] : null;
+
+ if (!tid || !pc || !x0) {
+ log(`[Manic EMU] Failed to extract registers, continuing...`);
+ continue;
+ }
+
+ const pcNum = littleEndianHexStringToNumber(pc);
+ const x0Num = littleEndianHexStringToNumber(x0);
+ const x1Num = x1 ? littleEndianHexStringToNumber(x1) : 0n;
+
+ let instructionResponse = send_command(`m${pcNum.toString(16)},4`);
+ let instrU32 = littleEndianHexToU32(instructionResponse);
+
+ // Check if it's a BRK instruction.
+ if (!isBrkInstruction(instrU32)) {
+ // Not a BRK instruction, skip it.
+ log(`[Manic EMU] Not a BRK instruction at PC=0x${pcNum.toString(16)}, skipping...`);
+ continue;
+ }
+
+ let brkImmediate = extractBrkImmediate(instrU32);
+
+ // Only handle brk #0x69 (JIT memory mapping request).
+ if (brkImmediate === 0x69) {
+ validBreakpoints++;
+
+ let jitPageAddress = x0Num;
+ // If x1 is 0, use the default size of 64KB (0x10000).
+ // Manic EMU's CodeBlock typically requests large memory chunks (tens of MB for CPU JIT)
+ // but also asks for smaller ones (like 4KB/16KB for SpinLock and Shader JIT).
+ let size = x1Num > 0n ? x1Num : 0x10000n;
+
+ log(`[Manic EMU] ----------------------------------------`);
+ log(`[Manic EMU] JIT Request #${validBreakpoints}`);
+ log(`[Manic EMU] Address: 0x${jitPageAddress.toString(16)}`);
+ log(`[Manic EMU] Size: 0x${size.toString(16)} (${formatSize(Number(size))})`);
+
+ // Call prepare_memory_region to mark the memory as executable.
+ let prepareJITPageResponse = prepare_memory_region(Number(jitPageAddress), Number(size));
+ log(`[Manic EMU] prepare_memory_region result: ${prepareJITPageResponse}`);
+
+ // Log statistics
+ totalMemoryMapped += size;
+ mappedRegions.push({
+ address: `0x${jitPageAddress.toString(16)}`,
+ size: Number(size),
+ index: validBreakpoints
+ });
+
+ log(`[Manic EMU] Total JIT memory mapped: ${formatSize(Number(totalMemoryMapped))}`);
+
+ // Set PC+4 to continue program execution
+ let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
+ send_command(`P20=${pcPlus4};thread:${tid};`);
+
+ log(`[Manic EMU] Resumed execution at PC=0x${(pcNum + 4n).toString(16)}`);
+ log(`[Manic EMU] ----------------------------------------`);
+
+ } else if (brkImmediate === 0x70 || brkImmediate === 0x71) {
+ // brk #0x70 and brk #0x71 are breakpoints used by some debuggers.
+ // Skip directly to PC+4 and continue execution.
+ log(`[Manic EMU] Debug breakpoint brk #0x${brkImmediate.toString(16)} at PC=0x${pcNum.toString(16)}, skipping...`);
+ let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
+ send_command(`P20=${pcPlus4};thread:${tid};`);
+
+ } else {
+ // Other BRK instructions, skip PC+4 and continue execution.
+ log(`[Manic EMU] Unknown brk #0x${brkImmediate.toString(16)} at PC=0x${pcNum.toString(16)}, skipping...`);
+ let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
+ send_command(`P20=${pcPlus4};thread:${tid};`);
+ }
+}
+
+// This line of code will never run because it's an infinite loop
+// log(`[Manic EMU] Script ended`);