diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index c1db201..2ecbf3c 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -745,6 +745,22 @@ final class JITEnableContext { script(pid, debugProxy, remoteServer, semaphore) semaphore.wait() emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger) + + // If the user stopped the hold (rather than the app detaching on its + // own), detach cleanly so the app keeps running instead of being + // torn down when the debug connection closes. + if cancellation.isCancelled { + var interrupt: UInt8 = 0x03 + _ = debug_proxy_send_raw(debugProxy, &interrupt, 1) + usleep(100_000) + do { + if let response = try sendDebugCommand("D", debugProxy: debugProxy) { + emitLog("Keep-alive detach response: \(response)", logger: logger) + } + } catch { + emitLog(error.localizedDescription, logger: logger) + } + } return } diff --git a/StikDebug/Features/InstalledApps/InstalledAppRows.swift b/StikDebug/Features/InstalledApps/InstalledAppRows.swift index 8820fb7..07a52af 100644 --- a/StikDebug/Features/InstalledApps/InstalledAppRows.swift +++ b/StikDebug/Features/InstalledApps/InstalledAppRows.swift @@ -242,6 +242,7 @@ struct LaunchAppRow: View { let bundleID: String let appName: String let isLaunching: Bool + let isHoldMode: Bool var launchAction: () -> Void @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @@ -251,11 +252,13 @@ struct LaunchAppRow: View { bundleID: String, appName: String, isLaunching: Bool, + isHoldMode: Bool = false, launchAction: @escaping () -> Void ) { self.bundleID = bundleID self.appName = appName self.isLaunching = isLaunching + self.isHoldMode = isHoldMode self.launchAction = launchAction _iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID)) } @@ -286,12 +289,12 @@ struct LaunchAppRow: View { if isLaunching { ProgressView().controlSize(.small) } else { - Text("Launch".localized) + Text((isHoldMode ? "Hold" : "Launch").localized) .font(.footnote.weight(.semibold)) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Capsule().fill(Color.accentColor.opacity(0.18))) - .foregroundStyle(Color.accentColor) + .background(Capsule().fill((isHoldMode ? Color.green : Color.accentColor).opacity(0.18))) + .foregroundStyle(isHoldMode ? Color.green : Color.accentColor) } } .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) @@ -306,14 +309,16 @@ struct LaunchAppRow: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel(String(format: "Launch %@".localized, appName)) + .accessibilityLabel(String(format: (isHoldMode ? "Hold %@ alive" : "Launch %@").localized, appName)) .accessibilityValue(accessibilityValue) .accessibilityHint(isLaunching ? "Launch request in progress.".localized - : "Double-tap to launch this app without enabling JIT.".localized) + : (isHoldMode + ? "Double-tap to hold this app alive in the background.".localized + : "Double-tap to launch this app without enabling JIT.".localized)) .accessibilityAddTraits(.isButton) .accessibilityRemoveTraits(.isStaticText) - .accessibilityAction(named: Text("Launch App".localized)) { + .accessibilityAction(named: Text((isHoldMode ? "Hold App Alive" : "Launch App").localized)) { guard !isLaunching else { return } launchAction() } diff --git a/StikDebug/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 0d5ef1e..5c776e4 100644 --- a/StikDebug/JSSupport/RunJSView.swift +++ b/StikDebug/JSSupport/RunJSView.swift @@ -22,12 +22,18 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable var debugProxy: OpaquePointer? var remoteServer: OpaquePointer? var semaphore: DispatchSemaphore? - - init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?) { + /// When set (background keep-alive holds), the script loop stops once this + /// token is cancelled. It is read only from the script's own thread — via + /// `should_continue()` and `send_command()` — so there is no concurrent + /// access to the debug proxy (which is not thread-safe). + var cancellation: HoldToken? + + init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?, cancellation: HoldToken? = nil) { self.pid = pid self.debugProxy = debugProxy self.remoteServer = remoteServer self.semaphore = semaphore + self.cancellation = cancellation } func runScript(path: URL, scriptName: String? = nil) throws { @@ -53,11 +59,11 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable self.context?.exception = JSValue(object: "Command should not be nil.", in: self.context!) return "" } - if self.executionInterrupted { + if self.executionInterrupted || self.cancellation?.isCancelled == true { self.context?.exception = JSValue(object: "Script execution is interrupted by StikDebug.", in: self.context!) return "" } - + return handleJSContextSendDebugCommand(self.context, commandStr, self.debugProxy) ?? "" } @@ -78,7 +84,17 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable let hasTXMFunction: @convention(block) () -> Bool = { return ProcessInfo.processInfo.hasTXM } - + + // Lets a hold script exit cleanly when the user stops the keep-alive + // session. Returns false once interrupted or cancelled; scripts that + // loop forever (e.g. universal.js hold mode) check this each iteration. + // Always true for a normal one-shot JIT run (no cancellation token). + let shouldContinueFunction: @convention(block) () -> Bool = { + if self.executionInterrupted { return false } + if self.cancellation?.isCancelled == true { return false } + return true + } + context = JSContext() context?.setObject(hasTXMFunction, forKeyedSubscript: "hasTXM" as NSString) context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString) @@ -86,7 +102,8 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable 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?.setObject(shouldContinueFunction, forKeyedSubscript: "should_continue" as NSString) + context?.evaluateScript(scriptContent) if let semaphore { semaphore.signal() diff --git a/StikDebug/Scripts/universal.js b/StikDebug/Scripts/universal.js index 26cf98d..6c33326 100644 --- a/StikDebug/Scripts/universal.js +++ b/StikDebug/Scripts/universal.js @@ -56,8 +56,15 @@ let attachResponse = send_command(`vAttach;${pid.toString(16)}`); log(`pid = ${pid}`); log(`attach_response = ${attachResponse}`); +// `should_continue()` is provided by the host. It returns false when a +// background keep-alive hold is stopped, letting this loop exit cleanly instead +// of holding the app forever. It is always true for a normal one-shot JIT run. +function shouldKeepRunning() { + return (typeof should_continue !== 'function') || should_continue(); +} + let totalBreakpoints = 0; -while (!detached) { +while (!detached && shouldKeepRunning()) { totalBreakpoints++; log(`Handling signal ${totalBreakpoints}`); diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index 3c0ae8f..5236301 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -51,13 +51,16 @@ final class BackgroundAliveManager: ObservableObject { /// - Parameter script: Optional JIT-script callback to run once (after /// attach, before the hold begins) so the app's assigned script executes /// in hold mode just like it does for a normal JIT run. - func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil) { + /// - Parameter token: Cancellation token for the hold. Pass the same token + /// the `script` callback was built with so that stopping the session also + /// stops the script's loop; defaults to a fresh token when there is no + /// script. + func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil, token: HoldToken = HoldToken()) { lock.lock() guard activeBundleID == nil else { lock.unlock() return } - let token = HoldToken() activeBundleID = bundleID self.token = token lock.unlock() diff --git a/StikDebug/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 0e609a3..3f1b9ea 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -72,6 +72,43 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } + /// The JIT script to run when holding an app alive in the background. + /// + /// On a TXM device (iOS 26+) JIT is serviced by handling `brk #0xf00d` + /// syscalls, so an app that is merely attached — with no script servicing + /// those traps — hangs the moment it requests executable memory. That is + /// why keep-alive "didn't work for any app": only apps whose name/bundle + /// matched an assigned or auto script got a callback. Fall back to the + /// bundled universal script so *any* app has its JIT traps serviced (and, + /// for non-JIT apps, its debug connection actively held) while alive. + /// + /// Returns `nil` on non-TXM devices, where JIT comes from `CS_DEBUGGED` + /// alone and a plain attach is enough to hold the app. + static func keepAliveScript(for bundleID: String, fileManager: FileManager = .default) -> (data: Data, name: String)? { + guard ProcessInfo.processInfo.hasTXM else { + return nil + } + + if let preferred = preferredScript(for: bundleID, fileManager: fileManager) { + return preferred + } + + return bundledScript(resourceName: "universal", fileName: "universal.js") + } + + /// Reads a bundled script straight from the app bundle (not the user's + /// scripts directory). Used for the keep-alive fallback so the current + /// shipped script — which supports `should_continue()` cancellation — is + /// always used, even for users whose documents copy predates it. + private static func bundledScript(resourceName: String, fileName: String) -> (data: Data, name: String)? { + guard let bundleURL = Bundle.main.url(forResource: resourceName, withExtension: "js"), + let data = try? Data(contentsOf: bundleURL) else { + return nil + } + + return (data, fileName) + } + static func favoriteAppName( for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index f833630..35d3fef 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -41,7 +41,14 @@ struct HomeView: View { } else { startJITInBackground(bundleID: selectedBundle, displayName: selectedName) } - }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }, onHoldApp: { selectedBundle, selectedName in + // Selecting an app from the "Other" tab while keep-alive is enabled + // holds it in the background (and shows the banner) instead of just + // launching it — the same behavior as the JIT tab. + bundleID = selectedBundle + Haptics.medium() + startKeepAlive(bundleID: selectedBundle, displayName: selectedName) + }) .overlay(alignment: .top) { if let activeApp = aliveManager.activeAppName { keepAliveBanner(appName: activeApp) @@ -291,17 +298,22 @@ struct HomeView: View { ) return } - // Resolve the app's assigned JIT script the same way a normal JIT run - // does, so the script also runs in hold mode instead of only attaching. + // Share one cancellation token between the hold and the JIT script so + // that stopping the session also stops the script's loop. + let token = HoldToken() + + // Resolve the JIT script to run in hold mode. On TXM devices this falls + // back to the bundled universal script for apps without an assigned or + // name-matched script, so keep-alive services JIT (and actively holds + // the debug connection) for *any* app instead of only known ones. var script: DebugAppCallback? = nil - if ProcessInfo.processInfo.hasTXM, - let preferred = ScriptStore.preferredScript(for: bundleID) { - script = getJsCallback(preferred.data, name: preferred.name) + if let holdScript = ScriptStore.keepAliveScript(for: bundleID) { + script = getJsCallback(holdScript.data, name: holdScript.name, cancellation: token) } let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) AccessibilityAnnouncer.announce(startingMessage) - BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script, token: token) } private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { @@ -325,12 +337,13 @@ struct HomeView: View { .accessibilityLabel(feedback.message) } - private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { + private func getJsCallback(_ script: Data, name: String? = nil, cancellation: HoldToken? = nil) -> DebugAppCallback { return { pid, debugProxyHandle, remoteServerHandle, semaphore in let model = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, remoteServer: remoteServerHandle, - semaphore: semaphore) + semaphore: semaphore, + cancellation: cancellation) DispatchQueue.main.async { scriptRunModel = model diff --git a/StikDebug/Views/InstalledAppsListView.swift b/StikDebug/Views/InstalledAppsListView.swift index 69437a6..d9d17e2 100644 --- a/StikDebug/Views/InstalledAppsListView.swift +++ b/StikDebug/Views/InstalledAppsListView.swift @@ -15,6 +15,10 @@ struct InstalledAppsListView: View { let onSelectApp: (String, String) -> Void let showDoneButton: Bool let onImportPairingFile: (() -> Void)? + /// Called when an app in the "Other" tab is tapped while background + /// keep-alive is enabled. When set, tapping holds the app alive instead of + /// launching it without a debugger. + let onHoldApp: ((String, String) -> Void)? private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard @@ -30,6 +34,7 @@ struct InstalledAppsListView: View { @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = [] @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] + @AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false @State private var launchingBundles: Set = [] @State private var launchFeedback: LaunchFeedback? @@ -45,11 +50,19 @@ struct InstalledAppsListView: View { init( onSelectApp: @escaping (String, String) -> Void, showDoneButton: Bool = true, - onImportPairingFile: (() -> Void)? = nil + onImportPairingFile: (() -> Void)? = nil, + onHoldApp: ((String, String) -> Void)? = nil ) { self.onSelectApp = onSelectApp self.showDoneButton = showDoneButton self.onImportPairingFile = onImportPairingFile + self.onHoldApp = onHoldApp + } + + /// Whether tapping an app in the "Other" tab should hold it alive rather + /// than launch it without a debugger. + private var holdModeEnabled: Bool { + keepAppAliveBackground && onHoldApp != nil } var body: some View { @@ -283,17 +296,29 @@ struct InstalledAppsListView: View { } } + @ViewBuilder + private var launchSectionFooter: some View { + if holdModeEnabled { + Text("Keep-Alive is on: tapping an app holds it alive in the background instead of just launching it.".localized) + } + } + private func launchAppSection(snapshot: LaunchAppListSnapshot) -> some View { - Section("All Apps".localized) { + Section(header: Text("All Apps".localized), footer: launchSectionFooter) { ForEach(snapshot.apps) { app in let isPinned = pinnedSystemApps.contains(app.bundleID) LaunchAppRow( bundleID: app.bundleID, appName: app.name, - isLaunching: launchingBundles.contains(app.bundleID) + isLaunching: launchingBundles.contains(app.bundleID), + isHoldMode: holdModeEnabled ) { - startLaunching(bundleID: app.bundleID, appName: app.name) + if holdModeEnabled { + onHoldApp?(app.bundleID, app.name) + } else { + startLaunching(bundleID: app.bundleID, appName: app.name) + } } .overlay(alignment: .topTrailing) { if isPinned {