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`);