mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
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:
commit
12684b7f03
8 changed files with 151 additions and 28 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue