diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 1acfce6..cfbdc12 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -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 = ""; }; + F00DCAFE000000000000E006 /* StikDebugWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */, + ); + path = StikDebugWidgets; + sourceTree = ""; + }; + F00DCAFE000000000000E007 /* StikDebugShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = StikDebugShared; + sourceTree = ""; + }; 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 = ""; @@ -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 */ diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 4ff12d5..6518d46 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -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() { diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index c1db201..2ecbf3c 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -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 } diff --git a/StikDebug/Features/InstalledApps/InstalledAppRows.swift b/StikDebug/Features/InstalledApps/InstalledAppRows.swift index 8820fb7..07a52af 100644 --- a/StikDebug/Features/InstalledApps/InstalledAppRows.swift +++ b/StikDebug/Features/InstalledApps/InstalledAppRows.swift @@ -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() } diff --git a/StikDebug/Info.plist b/StikDebug/Info.plist index 60fb164..9862fb8 100644 --- a/StikDebug/Info.plist +++ b/StikDebug/Info.plist @@ -21,6 +21,10 @@ _stikdebug._tcp _stikdebug._udp + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + UIBackgroundModes audio diff --git a/StikDebug/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 0d5ef1e..5c776e4 100644 --- a/StikDebug/JSSupport/RunJSView.swift +++ b/StikDebug/JSSupport/RunJSView.swift @@ -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() diff --git a/StikDebug/Scripts/universal.js b/StikDebug/Scripts/universal.js index 26cf98d..6c33326 100644 --- a/StikDebug/Scripts/universal.js +++ b/StikDebug/Scripts/universal.js @@ -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}`); diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index 3c0ae8f..9b2c391 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -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( diff --git a/StikDebug/Services/KeepAliveLiveActivity.swift b/StikDebug/Services/KeepAliveLiveActivity.swift new file mode 100644 index 0000000..16b54f7 --- /dev/null +++ b/StikDebug/Services/KeepAliveLiveActivity.swift @@ -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.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 + 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 + storage = nil + lock.unlock() + guard let activity else { return } + + Task { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + #endif +} diff --git a/StikDebug/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 0e609a3..3f1b9ea 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -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) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index f833630..35d3fef 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -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 diff --git a/StikDebug/Views/InstalledAppsListView.swift b/StikDebug/Views/InstalledAppsListView.swift index 69437a6..d9d17e2 100644 --- a/StikDebug/Views/InstalledAppsListView.swift +++ b/StikDebug/Views/InstalledAppsListView.swift @@ -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 = [] @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 { diff --git a/StikDebugShared/KeepAliveActivityAttributes.swift b/StikDebugShared/KeepAliveActivityAttributes.swift new file mode 100644 index 0000000..f047efd --- /dev/null +++ b/StikDebugShared/KeepAliveActivityAttributes.swift @@ -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 diff --git a/StikDebugWidgets/Info.plist b/StikDebugWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/StikDebugWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift new file mode 100644 index 0000000..ab95020 --- /dev/null +++ b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift @@ -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() + } +} diff --git a/StikDebugWidgets/StikDebugWidgetsBundle.swift b/StikDebugWidgets/StikDebugWidgetsBundle.swift new file mode 100644 index 0000000..2226e4b --- /dev/null +++ b/StikDebugWidgets/StikDebugWidgetsBundle.swift @@ -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() + } +}