diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 2b3368c..4ff12d5 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -24,7 +24,8 @@ enum AppBootstrapper { UserDefaults.Keys.txmOverride: false, UserDefaults.Keys.confirmExternalJITRequests: true, "keepAliveAudio": true, - "keepAliveLocation": true + "keepAliveLocation": true, + "keepAppAliveBackground": false ]) } diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index 656004b..45b7466 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -642,6 +642,105 @@ final class JITEnableContext { } } + /// Launches an app, attaches the debugger, and *holds* the session open so the + /// app keeps running in the background. + /// + /// A debugger-owned process is not suspended by iOS when it moves to the + /// background (the same reason an app run from Xcode keeps executing while + /// backgrounded). Attaching also sets `CS_DEBUGGED`, so JIT is enabled too. + /// We additionally lift the process memory limit to reduce the chance of a + /// background jetsam kill. The hold runs until `cancellation` is set, then we + /// interrupt and detach so the app continues running on its own. + /// + /// - Note: This keeps StikDebug's connection to *this* app alive; it cannot + /// revive an app iOS has already killed. Requires StikDebug itself to stay + /// running (see `DebugKeepAliveLease`). + func keepAppAlive(withBundleID bundleID: String, cancellation: HoldToken, logger: LogFunc?) -> Bool { + do { + try withConnectedDebugSession { remoteServer, debugProxy in + let pid = try withProcessControl(remoteServer: remoteServer) { processControl in + var pid: UInt64 = 0 + let ffiError = bundleID.withCString { bundleID in + process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, true, false, &pid) + } + if let ffiError { + throw error(from: ffiError, fallback: "Failed to launch app") + } + + // Best-effort: lift the memory limit so iOS is less likely to + // jetsam-kill the app while it is in the background. + if let limitError = process_control_disable_memory_limit(processControl, pid) { + let disableError = error(from: limitError, fallback: "Failed to disable memory limit") + emitLog("Keep-alive: could not disable memory limit: \(disableError.localizedDescription)", logger: logger) + } + + return Int32(truncatingIfNeeded: pid) + } + + try holdDebugSession(pid: pid, debugProxy: debugProxy, logger: logger, cancellation: cancellation) + } + + emitLog("Keep-alive session ended for \(bundleID)", logger: logger) + return true + } catch { + emitLog("Keep-alive session failed: \(error.localizedDescription)", logger: logger) + return false + } + } + + private func holdDebugSession(pid: Int32, debugProxy: OpaquePointer, logger: LogFunc?, cancellation: HoldToken) throws { + debug_proxy_send_ack(debugProxy) + debug_proxy_send_ack(debugProxy) + + do { + let response = try sendDebugCommand("QStartNoAckMode", debugProxy: debugProxy) ?? "" + emitLog("Keep-alive QStartNoAckMode = \(response)", logger: logger) + } catch { + emitLog(error.localizedDescription, logger: logger) + } + + debug_proxy_set_ack_mode(debugProxy, 0) + + let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" + let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) + + // Keep the RSD/lockdown tunnel trusted for the whole hold. + let heartbeat = try? connectHeartbeatKeepAlive(logger: logger) + try? heartbeat?.start() + defer { heartbeat?.stop() } + + // Continue the (suspended-launched) process so it runs. Sent raw and not + // awaited: a debugger-owned, running process is not stopped by iOS when + // backgrounded, so there are no stop replies to drain — we just hold the + // connection open. Polling a flag (rather than blocking on a socket read) + // keeps this single-threaded and cleanly cancellable. + sendContinue(debugProxy) + emitLog("Keep-alive: app is running and held under the debugger", logger: logger) + + while !cancellation.isCancelled { + Thread.sleep(forTimeInterval: 0.5) + } + + // Interrupt, then detach, so the app keeps running on its own afterward. + 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) + } + } + + private func sendContinue(_ debugProxy: OpaquePointer) { + // "$c#63" — RSP continue-all-threads packet (valid in no-ack mode). + var packet: [UInt8] = [0x24, 0x63, 0x23, 0x36, 0x33] + _ = debug_proxy_send_raw(debugProxy, &packet, packet.count) + } + func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { do { try ensureTunnel() diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift new file mode 100644 index 0000000..eab079b --- /dev/null +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -0,0 +1,115 @@ +// +// BackgroundAliveManager.swift +// StikDebug +// + +import Foundation +import UIKit + +/// Thread-safe cancellation flag for a held debug session. +final class HoldToken { + private let lock = NSLock() + private var cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } +} + +/// Owns an active "keep this app alive in the background" session. +/// +/// It launches the target app, holds the debugger attached to it (which stops +/// iOS from suspending it in the background), and keeps StikDebug itself alive +/// (`DebugKeepAliveLease`) for as long as the hold is running. Only one app can +/// be held at a time. This is experimental — see `JITEnableContext.keepAppAlive`. +final class BackgroundAliveManager: ObservableObject { + static let shared = BackgroundAliveManager() + + @Published private(set) var activeAppName: String? + + private let lock = NSLock() + private var token: HoldToken? + private var lease: DebugKeepAliveLease? + private var activeBundleID: String? + + private init() {} + + var isActive: Bool { + lock.lock() + defer { lock.unlock() } + return activeBundleID != nil + } + + func start(bundleID: String, displayName: String?) { + lock.lock() + guard activeBundleID == nil else { + lock.unlock() + return + } + let token = HoldToken() + activeBundleID = bundleID + self.token = token + lock.unlock() + + // Create the keep-alive lease outside the lock (it touches the main thread). + let lease = DebugKeepAliveLease() + lock.lock() + self.lease = lease + lock.unlock() + + let name = displayName ?? bundleID + DispatchQueue.main.async { self.activeAppName = name } + LogManager.shared.addInfoLog("Starting background keep-alive for \(name)") + + DispatchQueue.global(qos: .userInitiated).async { + let logger: LogFunc = { message in + if let message { LogManager.shared.addInfoLog(message) } + } + + let succeeded = JITEnableContext.shared.keepAppAlive( + withBundleID: bundleID, + cancellation: token, + logger: logger + ) + + self.lock.lock() + let stillCurrent = self.activeBundleID == bundleID && self.token === token + if stillCurrent { + self.activeBundleID = nil + self.token = nil + self.lease = nil + } + self.lock.unlock() + + lease.invalidate() + + DispatchQueue.main.async { + if stillCurrent { + self.activeAppName = nil + } + if !succeeded { + showAlert( + title: "Keep-Alive Ended", + message: "The background keep-alive session for \(name) could not start or ended early. Make sure the app is installed and that LocalDevVPN and the pairing file are active.", + showOk: true + ) + } + } + } + } + + func stop() { + lock.lock() + let token = self.token + lock.unlock() + token?.cancel() + } +} diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index a647853..c940409 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -11,8 +11,10 @@ struct HomeView: View { @AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false @AppStorage("bundleID") private var bundleID: String = "" @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true + @AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false @ObservedObject private var mounting = MountingProgress.shared + @ObservedObject private var aliveManager = BackgroundAliveManager.shared @State private var hasAppeared = false @State private var pendingJITEnableConfiguration: JITEnableConfiguration? @@ -34,8 +36,19 @@ struct HomeView: View { InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle Haptics.medium() - startJITInBackground(bundleID: selectedBundle, displayName: selectedName) + if keepAppAliveBackground { + startKeepAlive(bundleID: selectedBundle, displayName: selectedName) + } else { + startJITInBackground(bundleID: selectedBundle, displayName: selectedName) + } }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + .overlay(alignment: .top) { + if let activeApp = aliveManager.activeAppName { + keepAliveBanner(appName: activeApp) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .overlay(alignment: .bottom) { if let debugFeedback { debugFeedbackView(debugFeedback) @@ -43,6 +56,7 @@ struct HomeView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .animation(.default, value: aliveManager.activeAppName) .onAppear(perform: handleAppear) .onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification) .onReceive(timer) { _ in @@ -238,6 +252,50 @@ struct HomeView: View { } } + private func keepAliveBanner(appName: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "bolt.circle.fill") + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 1) { + Text(String(format: "Keeping %@ alive".localized, appName)) + .font(.subheadline.weight(.semibold)) + Text("Held in the background".localized) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Button("Stop".localized) { + Haptics.medium() + BackgroundAliveManager.shared.stop() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(.red) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Capsule().fill(.ultraThinMaterial)) + .shadow(radius: 4) + .padding(.horizontal, 20) + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: "Keeping %@ alive in the background".localized, appName)) + } + + private func startKeepAlive(bundleID: String, displayName: String?) { + let name = displayName ?? bundleID + guard !aliveManager.isActive else { + showAlert( + title: "Keep-Alive Active".localized, + message: "StikDebug is already holding an app alive in the background. Stop it before starting another.".localized, + showOk: true + ) + return + } + let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) + AccessibilityAnnouncer.announce(startingMessage) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName) + } + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index 52d1040..836d837 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true @AppStorage("keepAliveAudio") private var keepAliveAudio = true @AppStorage("keepAliveLocation") private var keepAliveLocation = true + @AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false @AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress @State private var isShowingPairingFilePicker = false @@ -109,6 +110,14 @@ struct SettingsView: View { BackgroundLocationManager.shared.refreshFromSettings() } + Toggle(isOn: $keepAppAliveBackground) { + VStack(alignment: .leading, spacing: 2) { + Text("Hold App Alive in Background (Experimental)") + Text("Tapping an app keeps the debugger attached so iOS doesn't suspend it in the background. Enables JIT too. Higher battery use; can't revive an app iOS already closed.") + .font(.caption).foregroundStyle(.secondary) + } + } + } header: { Text("Background Keep-Alive") }