Fix experimental keep-alive, wire it into "Other" tab, add Dynamic Island Live Activity

Keep-alive for any app:
- On TXM devices (iOS 26+), keep-alive now falls back to the bundled universal
  JIT script for apps with no assigned/name-matched script. Previously only known
  apps got a script callback, so a generic app's JIT brk syscalls went unserviced
  and the app hung / was killed in the background — the reason keep-alive "didn't
  work for any app". The fallback is read straight from the app bundle so the
  current, cancellation-aware script is always used.
- Stopping a hold now reliably stops the script loop: the hold and its JIT script
  share one cancellation token, exposed to JS as should_continue() and honored by
  send_command(). universal.js checks should_continue() each iteration. On stop we
  interrupt + detach so the held app keeps running instead of being torn down. The
  cancellation flag is only read from the script's own thread, so there is no
  unsafe concurrent access to the (non-thread-safe) debug proxy.

Banner from the "Other" tab:
- Selecting an app in the "Other" (launch) tab while keep-alive is enabled now
  holds it in the background and shows the banner, instead of silently launching
  without a debugger. The launch rows show a green "Hold" action and a footer note
  in this mode.

Dynamic Island / Live Activity:
- New StikDebugWidgets widget-extension target hosting a keep-alive Live Activity,
  with compact/expanded/minimal Dynamic Island presentations and a Lock Screen
  view. Shared KeepAliveActivityAttributes lives in a StikDebugShared group used by
  both the app and the extension.
- BackgroundAliveManager starts the activity when a hold begins and ends it when
  the hold stops; stale activities from a previous run are cleared at launch.
- Added NSSupportsLiveActivities to the app Info.plist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT
This commit is contained in:
Claude 2026-07-15 10:47:40 +00:00
parent 5c3c37a1e4
commit 23e8052c7b
No known key found for this signature in database
16 changed files with 579 additions and 29 deletions

View file

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; };
F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -26,6 +27,13 @@
remoteGlobalIDString = DC6F1D362D94EADD0071B2B6;
remoteInfo = StikDebug;
};
F00DCAFE000000000000E00D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */;
proxyType = 1;
remoteGlobalIDString = F00DCAFE000000000000E001;
remoteInfo = StikDebugWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@ -35,6 +43,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@ -47,6 +56,7 @@
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; };
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F00DCAFE000000000000E002 /* StikDebugWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = StikDebugWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -57,6 +67,13 @@
);
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
};
F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = F00DCAFE000000000000E001 /* StikDebugWidgets */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -68,6 +85,19 @@
path = StikDebug;
sourceTree = "<group>";
};
F00DCAFE000000000000E006 /* StikDebugWidgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */,
);
path = StikDebugWidgets;
sourceTree = "<group>";
};
F00DCAFE000000000000E007 /* StikDebugShared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = StikDebugShared;
sourceTree = "<group>";
};
DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = StikDebugTests;
@ -104,6 +134,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F00DCAFE000000000000E004 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -111,6 +148,8 @@
isa = PBXGroup;
children = (
DC6F1D392D94EADD0071B2B6 /* StikDebug */,
F00DCAFE000000000000E007 /* StikDebugShared */,
F00DCAFE000000000000E006 /* StikDebugWidgets */,
DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */,
DC6F1D552D94EADF0071B2B6 /* StikDebugUITests */,
DC6F1D752D94EB620071B2B6 /* Frameworks */,
@ -124,6 +163,7 @@
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */,
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */,
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */,
F00DCAFE000000000000E002 /* StikDebugWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@ -152,9 +192,11 @@
buildRules = (
);
dependencies = (
F00DCAFE000000000000E00C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
DC6F1D392D94EADD0071B2B6 /* StikDebug */,
F00DCAFE000000000000E007 /* StikDebugShared */,
);
name = StikDebug;
packageProductDependencies = (
@ -165,6 +207,29 @@
productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */;
productType = "com.apple.product-type.application";
};
F00DCAFE000000000000E001 /* StikDebugWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */;
buildPhases = (
F00DCAFE000000000000E003 /* Sources */,
F00DCAFE000000000000E004 /* Frameworks */,
F00DCAFE000000000000E005 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
F00DCAFE000000000000E006 /* StikDebugWidgets */,
F00DCAFE000000000000E007 /* StikDebugShared */,
);
name = StikDebugWidgets;
packageProductDependencies = (
);
productName = StikDebugWidgets;
productReference = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
DC6F1D472D94EADF0071B2B6 /* StikDebugTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = DC6F1D5F2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebugTests" */;
@ -233,6 +298,9 @@
CreatedOnToolsVersion = 16.2;
TestTargetID = DC6F1D362D94EADD0071B2B6;
};
F00DCAFE000000000000E001 = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */;
@ -255,6 +323,7 @@
projectRoot = "";
targets = (
DC6F1D362D94EADD0071B2B6 /* StikDebug */,
F00DCAFE000000000000E001 /* StikDebugWidgets */,
DC6F1D472D94EADF0071B2B6 /* StikDebugTests */,
DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */,
);
@ -283,6 +352,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F00DCAFE000000000000E005 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -307,6 +383,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F00DCAFE000000000000E003 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -320,6 +403,11 @@
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
targetProxy = DC6F1D532D94EADF0071B2B6 /* PBXContainerItemProxy */;
};
F00DCAFE000000000000E00C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = F00DCAFE000000000000E001 /* StikDebugWidgets */;
targetProxy = F00DCAFE000000000000E00D /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@ -668,6 +756,64 @@
};
name = Release;
};
F00DCAFE000000000000E009 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StikDebugWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = StikDebug;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
F00DCAFE000000000000E00A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = StikDebugWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = StikDebug;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -707,6 +853,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F00DCAFE000000000000E009 /* Debug */,
F00DCAFE000000000000E00A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */

View file

@ -13,6 +13,9 @@ enum AppBootstrapper {
startConfiguredKeepAliveServices()
applyDocumentPickerCopyWorkaround()
NetworkPathMonitor.shared.start()
// No hold is active at launch, so clear any Live Activity a previous
// (possibly force-quit) run left showing in the Dynamic Island.
KeepAliveLiveActivity.endStaleActivities()
}
private static func registerDefaultSettings() {

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

@ -21,6 +21,10 @@
<string>_stikdebug._tcp</string>
<string>_stikdebug._udp</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>

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()
@ -69,7 +72,12 @@ final class BackgroundAliveManager: ObservableObject {
lock.unlock()
let name = displayName ?? bundleID
DispatchQueue.main.async { self.activeAppName = name }
DispatchQueue.main.async {
self.activeAppName = name
// Surface the hold in the Dynamic Island / Lock Screen so it stays
// visible while the user is in the held app.
KeepAliveLiveActivity.start(appName: name, bundleID: bundleID)
}
LogManager.shared.addInfoLog("Starting background keep-alive for \(name)")
DispatchQueue.global(qos: .userInitiated).async {
@ -98,6 +106,7 @@ final class BackgroundAliveManager: ObservableObject {
DispatchQueue.main.async {
if stillCurrent {
self.activeAppName = nil
KeepAliveLiveActivity.end()
}
if !succeeded {
showAlert(

View file

@ -0,0 +1,114 @@
//
// KeepAliveLiveActivity.swift
// StikDebug
//
// App-side controller for the background keep-alive Live Activity. Starts a
// Live Activity when a hold begins so it shows in the Dynamic Island (on
// compatible iPhones) and on the Lock Screen, and ends it when the hold stops.
//
import Foundation
#if canImport(ActivityKit)
import ActivityKit
#endif
enum KeepAliveLiveActivity {
private static let lock = NSLock()
/// Type-erased storage for the current `Activity`, so this enum has no
/// availability requirement of its own and callers don't need to guard.
private static var storage: Any?
/// Start (or replace) the keep-alive Live Activity for the given app.
static func start(appName: String, bundleID: String) {
#if canImport(ActivityKit)
guard #available(iOS 16.1, *) else { return }
startActivity(appName: appName, bundleID: bundleID)
#endif
}
/// Update the app name shown by the current Live Activity, if any.
static func update(appName: String) {
#if canImport(ActivityKit)
guard #available(iOS 16.1, *) else { return }
updateActivity(appName: appName)
#endif
}
/// End the current keep-alive Live Activity, if any.
static func end() {
#if canImport(ActivityKit)
guard #available(iOS 16.1, *) else { return }
endActivity()
#endif
}
/// End any keep-alive activities left running by a previous launch (for
/// example if the app was force-quit or crashed mid-hold). Call this once at
/// startup, when no hold is active.
static func endStaleActivities() {
#if canImport(ActivityKit)
guard #available(iOS 16.1, *) else { return }
for activity in Activity<KeepAliveActivityAttributes>.activities {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
lock.lock()
storage = nil
lock.unlock()
#endif
}
#if canImport(ActivityKit)
@available(iOS 16.1, *)
private static func startActivity(appName: String, bundleID: String) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
LogManager.shared.addInfoLog("Keep-alive Live Activity not started: Live Activities are disabled in Settings")
return
}
// Only one hold runs at a time, so make sure any stale activity is gone.
endActivity()
let attributes = KeepAliveActivityAttributes(appName: appName, bundleID: bundleID)
let state = KeepAliveActivityAttributes.ContentState(appName: appName)
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: state, staleDate: nil)
)
lock.lock()
storage = activity
lock.unlock()
} catch {
LogManager.shared.addErrorLog("Keep-alive Live Activity failed to start: \(error.localizedDescription)")
}
}
@available(iOS 16.1, *)
private static func updateActivity(appName: String) {
lock.lock()
let activity = storage as? Activity<KeepAliveActivityAttributes>
lock.unlock()
guard let activity else { return }
let state = KeepAliveActivityAttributes.ContentState(appName: appName)
Task {
await activity.update(ActivityContent(state: state, staleDate: nil))
}
}
@available(iOS 16.1, *)
private static func endActivity() {
lock.lock()
let activity = storage as? Activity<KeepAliveActivityAttributes>
storage = nil
lock.unlock()
guard let activity else { return }
Task {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
#endif
}

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 {

View file

@ -0,0 +1,29 @@
//
// KeepAliveActivityAttributes.swift
// StikDebug
//
// Shared between the app (which starts/updates/ends the activity) and the
// widget extension (which renders it in the Dynamic Island / on the Lock
// Screen). Keep this file in the StikDebugShared group so both targets compile
// the same type.
//
import Foundation
#if canImport(ActivityKit)
import ActivityKit
@available(iOS 16.1, *)
struct KeepAliveActivityAttributes: ActivityAttributes {
/// The parts of the activity that change while it is live.
struct ContentState: Codable, Hashable {
/// The display name of the app currently being held alive.
var appName: String
}
/// The app being held alive when the activity started.
var appName: String
/// The bundle identifier of that app.
var bundleID: String
}
#endif

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,88 @@
//
// KeepAliveLiveActivityWidget.swift
// StikDebugWidgets
//
// Live Activity presentation for the background keep-alive session. Shows the
// held app in the Dynamic Island (compact, expanded, and minimal) and on the
// Lock Screen.
//
import WidgetKit
import SwiftUI
import ActivityKit
@available(iOS 16.1, *)
struct KeepAliveLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: KeepAliveActivityAttributes.self) { context in
// Lock Screen / banner presentation.
KeepAliveLockScreenView(appName: context.state.appName)
.activityBackgroundTint(Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.green)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bolt.circle.fill")
.font(.title2)
.foregroundStyle(.green)
.padding(.leading, 4)
}
DynamicIslandExpandedRegion(.trailing) {
Text("JIT")
.font(.caption.weight(.bold))
.foregroundStyle(.green)
.padding(.trailing, 4)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.appName)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
DynamicIslandExpandedRegion(.bottom) {
Text("Held alive in the background")
.font(.caption)
.foregroundStyle(.secondary)
}
} compactLeading: {
Image(systemName: "bolt.circle.fill")
.foregroundStyle(.green)
} compactTrailing: {
Text(context.state.appName)
.font(.caption2)
.lineLimit(1)
.frame(maxWidth: 64)
} minimal: {
Image(systemName: "bolt.circle.fill")
.foregroundStyle(.green)
}
.keylineTint(.green)
}
}
}
@available(iOS 16.1, *)
private struct KeepAliveLockScreenView: View {
let appName: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "bolt.circle.fill")
.font(.title)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Keeping \(appName) alive")
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text("Held in the background")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding()
}
}

View file

@ -0,0 +1,17 @@
//
// StikDebugWidgetsBundle.swift
// StikDebugWidgets
//
// Widget extension bundle. Hosts the keep-alive Live Activity so it can be
// rendered in the Dynamic Island and on the Lock Screen.
//
import WidgetKit
import SwiftUI
@main
struct StikDebugWidgetsBundle: WidgetBundle {
var body: some Widget {
KeepAliveLiveActivityWidget()
}
}