From 23e8052c7bfc68a35580cd771bed565bf296f3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 10:47:40 +0000 Subject: [PATCH 1/2] Fix experimental keep-alive, wire it into "Other" tab, add Dynamic Island Live Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug.xcodeproj/project.pbxproj | 155 ++++++++++++++++++ StikDebug/App/AppBootstrapper.swift | 3 + StikDebug/Device/JITEnableContext.swift | 16 ++ .../InstalledApps/InstalledAppRows.swift | 17 +- StikDebug/Info.plist | 4 + StikDebug/JSSupport/RunJSView.swift | 29 +++- StikDebug/Scripts/universal.js | 9 +- .../Services/BackgroundAliveManager.swift | 15 +- .../Services/KeepAliveLiveActivity.swift | 114 +++++++++++++ StikDebug/Support/ScriptStore.swift | 37 +++++ StikDebug/Views/HomeView.swift | 31 +++- StikDebug/Views/InstalledAppsListView.swift | 33 +++- .../KeepAliveActivityAttributes.swift | 29 ++++ StikDebugWidgets/Info.plist | 11 ++ .../KeepAliveLiveActivityWidget.swift | 88 ++++++++++ StikDebugWidgets/StikDebugWidgetsBundle.swift | 17 ++ 16 files changed, 579 insertions(+), 29 deletions(-) create mode 100644 StikDebug/Services/KeepAliveLiveActivity.swift create mode 100644 StikDebugShared/KeepAliveActivityAttributes.swift create mode 100644 StikDebugWidgets/Info.plist create mode 100644 StikDebugWidgets/KeepAliveLiveActivityWidget.swift create mode 100644 StikDebugWidgets/StikDebugWidgetsBundle.swift 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() + } +} From e4f02100ee4637db718234d3dbebbb0974ab9215 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 10:54:26 +0000 Subject: [PATCH 2/2] Remove widget extension, keep the keep-alive banner in-app only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the StikDebugWidgets extension target, the shared ActivityAttributes, and the KeepAliveLiveActivity controller — no Dynamic Island / Lock Screen presentation. The banner stays as the existing in-app overlay in HomeView, now also shown when holding an app from the "Other" tab. project.pbxproj, Info.plist, and AppBootstrapper.swift are back to their pre-widget state. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug.xcodeproj/project.pbxproj | 155 ------------------ StikDebug/App/AppBootstrapper.swift | 3 - StikDebug/Info.plist | 4 - .../Services/BackgroundAliveManager.swift | 8 +- .../Services/KeepAliveLiveActivity.swift | 114 ------------- .../KeepAliveActivityAttributes.swift | 29 ---- StikDebugWidgets/Info.plist | 11 -- .../KeepAliveLiveActivityWidget.swift | 88 ---------- StikDebugWidgets/StikDebugWidgetsBundle.swift | 17 -- 9 files changed, 1 insertion(+), 428 deletions(-) delete mode 100644 StikDebug/Services/KeepAliveLiveActivity.swift delete mode 100644 StikDebugShared/KeepAliveActivityAttributes.swift delete mode 100644 StikDebugWidgets/Info.plist delete mode 100644 StikDebugWidgets/KeepAliveLiveActivityWidget.swift delete mode 100644 StikDebugWidgets/StikDebugWidgetsBundle.swift diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index cfbdc12..1acfce6 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* 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 */ @@ -27,13 +26,6 @@ remoteGlobalIDString = DC6F1D362D94EADD0071B2B6; remoteInfo = StikDebug; }; - F00DCAFE000000000000E00D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = F00DCAFE000000000000E001; - remoteInfo = StikDebugWidgets; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -43,7 +35,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -56,7 +47,6 @@ 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 */ @@ -67,13 +57,6 @@ ); 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 */ @@ -85,19 +68,6 @@ 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; @@ -134,13 +104,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E004 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -148,8 +111,6 @@ isa = PBXGroup; children = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E007 /* StikDebugShared */, - F00DCAFE000000000000E006 /* StikDebugWidgets */, DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */, DC6F1D552D94EADF0071B2B6 /* StikDebugUITests */, DC6F1D752D94EB620071B2B6 /* Frameworks */, @@ -163,7 +124,6 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */, DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */, DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */, - F00DCAFE000000000000E002 /* StikDebugWidgets.appex */, ); name = Products; sourceTree = ""; @@ -192,11 +152,9 @@ buildRules = ( ); dependencies = ( - F00DCAFE000000000000E00C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E007 /* StikDebugShared */, ); name = StikDebug; packageProductDependencies = ( @@ -207,29 +165,6 @@ 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" */; @@ -298,9 +233,6 @@ CreatedOnToolsVersion = 16.2; TestTargetID = DC6F1D362D94EADD0071B2B6; }; - F00DCAFE000000000000E001 = { - CreatedOnToolsVersion = 16.2; - }; }; }; buildConfigurationList = DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */; @@ -323,7 +255,6 @@ projectRoot = ""; targets = ( DC6F1D362D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E001 /* StikDebugWidgets */, DC6F1D472D94EADF0071B2B6 /* StikDebugTests */, DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */, ); @@ -352,13 +283,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -383,13 +307,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E003 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -403,11 +320,6 @@ target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; targetProxy = DC6F1D532D94EADF0071B2B6 /* PBXContainerItemProxy */; }; - F00DCAFE000000000000E00C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = F00DCAFE000000000000E001 /* StikDebugWidgets */; - targetProxy = F00DCAFE000000000000E00D /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -756,64 +668,6 @@ }; 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 */ @@ -853,15 +707,6 @@ 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 6518d46..4ff12d5 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -13,9 +13,6 @@ 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/Info.plist b/StikDebug/Info.plist index 9862fb8..60fb164 100644 --- a/StikDebug/Info.plist +++ b/StikDebug/Info.plist @@ -21,10 +21,6 @@ _stikdebug._tcp _stikdebug._udp - NSSupportsLiveActivities - - NSSupportsLiveActivitiesFrequentUpdates - UIBackgroundModes audio diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index 9b2c391..5236301 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -72,12 +72,7 @@ final class BackgroundAliveManager: ObservableObject { lock.unlock() let name = displayName ?? bundleID - 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) - } + DispatchQueue.main.async { self.activeAppName = name } LogManager.shared.addInfoLog("Starting background keep-alive for \(name)") DispatchQueue.global(qos: .userInitiated).async { @@ -106,7 +101,6 @@ 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 deleted file mode 100644 index 16b54f7..0000000 --- a/StikDebug/Services/KeepAliveLiveActivity.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// 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/StikDebugShared/KeepAliveActivityAttributes.swift b/StikDebugShared/KeepAliveActivityAttributes.swift deleted file mode 100644 index f047efd..0000000 --- a/StikDebugShared/KeepAliveActivityAttributes.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 deleted file mode 100644 index 0f118fb..0000000 --- a/StikDebugWidgets/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift deleted file mode 100644 index ab95020..0000000 --- a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// 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 deleted file mode 100644 index 2226e4b..0000000 --- a/StikDebugWidgets/StikDebugWidgetsBundle.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// 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() - } -}