Merge pull request #7 from dizzafizza/claude/keep-alive-banner-fixes-n830n2

Fix experimental keep-alive, wire it into "Other" tab
This commit is contained in:
James 2026-07-15 15:10:45 -07:00 • committed by GitHub
commit 12684b7f03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 151 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String> = []
@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 {