Add experimental "hold app alive in background" mode

Targets the real problem: an app (e.g. Roblox) getting closed/disconnected by
iOS once it's in the background. iOS won't let one app keep another alive —
except that a process a debugger is attached to is not suspended on
backgrounding (the same reason an Xcode-run app keeps executing in the
background). This uses that.

- JITEnableContext.keepAppAlive: launch the app suspended, vAttach (which sets
  CS_DEBUGGED so JIT is on and marks the process debugger-owned), disable its
  memory limit to reduce background jetsam kills, send a raw continue so it
  runs, then hold the debug connection open with a heartbeat until the user
  stops it — then interrupt + detach so the app keeps running on its own. The
  hold is a single-threaded sleep-poll (no blocking socket reads, no concurrent
  handle access), so it's cleanly cancellable.
- BackgroundAliveManager + HoldToken: own one active hold, keep StikDebug itself
  alive via DebugKeepAliveLease for the duration, publish the active app name.
- UI: an experimental Settings toggle ("Hold App Alive in Background"); when on,
  tapping an app in Home holds it alive instead of a normal one-shot JIT, and a
  banner with a Stop button appears while a hold is active.

Caveats (documented in-app): experimental and unverified on-device; requires
StikDebug to stay running, so it's best-effort not bulletproof; cannot revive
an app iOS already killed; higher battery use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qznh32XffG6vwFLMcwm1Mq
This commit is contained in:
Claude 2026-07-14 12:53:28 +00:00
parent 656ec6d512
commit 84a5a5eff6
No known key found for this signature in database
5 changed files with 284 additions and 2 deletions

View file

@ -24,7 +24,8 @@ enum AppBootstrapper {
UserDefaults.Keys.txmOverride: false,
UserDefaults.Keys.confirmExternalJITRequests: true,
"keepAliveAudio": true,
"keepAliveLocation": true
"keepAliveLocation": true,
"keepAppAliveBackground": false
])
}

View file

@ -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) ?? "<nil>"
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) ?? "<nil>"
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()

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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")
}