mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
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:
parent
656ec6d512
commit
84a5a5eff6
5 changed files with 284 additions and 2 deletions
|
|
@ -24,7 +24,8 @@ enum AppBootstrapper {
|
|||
UserDefaults.Keys.txmOverride: false,
|
||||
UserDefaults.Keys.confirmExternalJITRequests: true,
|
||||
"keepAliveAudio": true,
|
||||
"keepAliveLocation": true
|
||||
"keepAliveLocation": true,
|
||||
"keepAppAliveBackground": false
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
115
StikDebug/Services/BackgroundAliveManager.swift
Normal file
115
StikDebug/Services/BackgroundAliveManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue