mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Fix experimental keep-alive, wire it into "Other" tab, add Dynamic Island Live Activity
Keep-alive for any app: - On TXM devices (iOS 26+), keep-alive now falls back to the bundled universal JIT script for apps with no assigned/name-matched script. Previously only known apps got a script callback, so a generic app's JIT brk syscalls went unserviced and the app hung / was killed in the background — the reason keep-alive "didn't work for any app". The fallback is read straight from the app bundle so the current, cancellation-aware script is always used. - Stopping a hold now reliably stops the script loop: the hold and its JIT script share one cancellation token, exposed to JS as should_continue() and honored by send_command(). universal.js checks should_continue() each iteration. On stop we interrupt + detach so the held app keeps running instead of being torn down. The cancellation flag is only read from the script's own thread, so there is no unsafe concurrent access to the (non-thread-safe) debug proxy. Banner from the "Other" tab: - Selecting an app in the "Other" (launch) tab while keep-alive is enabled now holds it in the background and shows the banner, instead of silently launching without a debugger. The launch rows show a green "Hold" action and a footer note in this mode. Dynamic Island / Live Activity: - New StikDebugWidgets widget-extension target hosting a keep-alive Live Activity, with compact/expanded/minimal Dynamic Island presentations and a Lock Screen view. Shared KeepAliveActivityAttributes lives in a StikDebugShared group used by both the app and the extension. - BackgroundAliveManager starts the activity when a hold begins and ends it when the hold stops; stale activities from a previous run are cleared at launch. - Added NSSupportsLiveActivities to the app Info.plist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT
This commit is contained in:
parent
5c3c37a1e4
commit
23e8052c7b
16 changed files with 579 additions and 29 deletions
|
|
@ -9,6 +9,7 @@
|
|||
/* Begin PBXBuildFile section */
|
||||
68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; };
|
||||
68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; };
|
||||
F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
|
@ -26,6 +27,13 @@
|
|||
remoteGlobalIDString = DC6F1D362D94EADD0071B2B6;
|
||||
remoteInfo = StikDebug;
|
||||
};
|
||||
F00DCAFE000000000000E00D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = F00DCAFE000000000000E001;
|
||||
remoteInfo = StikDebugWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -35,6 +43,7 @@
|
|||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
@ -47,6 +56,7 @@
|
|||
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F00DCAFE000000000000E002 /* StikDebugWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = StikDebugWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
|
@ -57,6 +67,13 @@
|
|||
);
|
||||
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
|
||||
};
|
||||
F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = F00DCAFE000000000000E001 /* StikDebugWidgets */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
|
|
@ -68,6 +85,19 @@
|
|||
path = StikDebug;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F00DCAFE000000000000E006 /* StikDebugWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */,
|
||||
);
|
||||
path = StikDebugWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F00DCAFE000000000000E007 /* StikDebugShared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = StikDebugShared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = StikDebugTests;
|
||||
|
|
@ -104,6 +134,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F00DCAFE000000000000E004 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
|
|
@ -111,6 +148,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
DC6F1D392D94EADD0071B2B6 /* StikDebug */,
|
||||
F00DCAFE000000000000E007 /* StikDebugShared */,
|
||||
F00DCAFE000000000000E006 /* StikDebugWidgets */,
|
||||
DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */,
|
||||
DC6F1D552D94EADF0071B2B6 /* StikDebugUITests */,
|
||||
DC6F1D752D94EB620071B2B6 /* Frameworks */,
|
||||
|
|
@ -124,6 +163,7 @@
|
|||
DC6F1D372D94EADD0071B2B6 /* StikDebug.app */,
|
||||
DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */,
|
||||
DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */,
|
||||
F00DCAFE000000000000E002 /* StikDebugWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -152,9 +192,11 @@
|
|||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
F00DCAFE000000000000E00C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
DC6F1D392D94EADD0071B2B6 /* StikDebug */,
|
||||
F00DCAFE000000000000E007 /* StikDebugShared */,
|
||||
);
|
||||
name = StikDebug;
|
||||
packageProductDependencies = (
|
||||
|
|
@ -165,6 +207,29 @@
|
|||
productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
F00DCAFE000000000000E001 /* StikDebugWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */;
|
||||
buildPhases = (
|
||||
F00DCAFE000000000000E003 /* Sources */,
|
||||
F00DCAFE000000000000E004 /* Frameworks */,
|
||||
F00DCAFE000000000000E005 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
F00DCAFE000000000000E006 /* StikDebugWidgets */,
|
||||
F00DCAFE000000000000E007 /* StikDebugShared */,
|
||||
);
|
||||
name = StikDebugWidgets;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = StikDebugWidgets;
|
||||
productReference = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
DC6F1D472D94EADF0071B2B6 /* StikDebugTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = DC6F1D5F2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebugTests" */;
|
||||
|
|
@ -233,6 +298,9 @@
|
|||
CreatedOnToolsVersion = 16.2;
|
||||
TestTargetID = DC6F1D362D94EADD0071B2B6;
|
||||
};
|
||||
F00DCAFE000000000000E001 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */;
|
||||
|
|
@ -255,6 +323,7 @@
|
|||
projectRoot = "";
|
||||
targets = (
|
||||
DC6F1D362D94EADD0071B2B6 /* StikDebug */,
|
||||
F00DCAFE000000000000E001 /* StikDebugWidgets */,
|
||||
DC6F1D472D94EADF0071B2B6 /* StikDebugTests */,
|
||||
DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */,
|
||||
);
|
||||
|
|
@ -283,6 +352,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F00DCAFE000000000000E005 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
|
@ -307,6 +383,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F00DCAFE000000000000E003 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
|
|
@ -320,6 +403,11 @@
|
|||
target = DC6F1D362D94EADD0071B2B6 /* StikDebug */;
|
||||
targetProxy = DC6F1D532D94EADF0071B2B6 /* PBXContainerItemProxy */;
|
||||
};
|
||||
F00DCAFE000000000000E00C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = F00DCAFE000000000000E001 /* StikDebugWidgets */;
|
||||
targetProxy = F00DCAFE000000000000E00D /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
|
|
@ -668,6 +756,64 @@
|
|||
};
|
||||
name = Release;
|
||||
};
|
||||
F00DCAFE000000000000E009 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = StikDebugWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = StikDebug;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00DCAFE000000000000E00A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = StikDebugWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = StikDebug;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
|
|
@ -707,6 +853,15 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00DCAFE000000000000E009 /* Debug */,
|
||||
F00DCAFE000000000000E00A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -745,6 +745,22 @@ final class JITEnableContext {
|
|||
script(pid, debugProxy, remoteServer, semaphore)
|
||||
semaphore.wait()
|
||||
emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger)
|
||||
|
||||
// If the user stopped the hold (rather than the app detaching on its
|
||||
// own), detach cleanly so the app keeps running instead of being
|
||||
// torn down when the debug connection closes.
|
||||
if cancellation.isCancelled {
|
||||
var interrupt: UInt8 = 0x03
|
||||
_ = debug_proxy_send_raw(debugProxy, &interrupt, 1)
|
||||
usleep(100_000)
|
||||
do {
|
||||
if let response = try sendDebugCommand("D", debugProxy: debugProxy) {
|
||||
emitLog("Keep-alive detach response: \(response)", logger: logger)
|
||||
}
|
||||
} catch {
|
||||
emitLog(error.localizedDescription, logger: logger)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ struct LaunchAppRow: View {
|
|||
let bundleID: String
|
||||
let appName: String
|
||||
let isLaunching: Bool
|
||||
let isHoldMode: Bool
|
||||
var launchAction: () -> Void
|
||||
|
||||
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
||||
|
|
@ -251,11 +252,13 @@ struct LaunchAppRow: View {
|
|||
bundleID: String,
|
||||
appName: String,
|
||||
isLaunching: Bool,
|
||||
isHoldMode: Bool = false,
|
||||
launchAction: @escaping () -> Void
|
||||
) {
|
||||
self.bundleID = bundleID
|
||||
self.appName = appName
|
||||
self.isLaunching = isLaunching
|
||||
self.isHoldMode = isHoldMode
|
||||
self.launchAction = launchAction
|
||||
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
|
||||
}
|
||||
|
|
@ -286,12 +289,12 @@ struct LaunchAppRow: View {
|
|||
if isLaunching {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Launch".localized)
|
||||
Text((isHoldMode ? "Hold" : "Launch").localized)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.background(Capsule().fill((isHoldMode ? Color.green : Color.accentColor).opacity(0.18)))
|
||||
.foregroundStyle(isHoldMode ? Color.green : Color.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
||||
|
|
@ -306,14 +309,16 @@ struct LaunchAppRow: View {
|
|||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(format: "Launch %@".localized, appName))
|
||||
.accessibilityLabel(String(format: (isHoldMode ? "Hold %@ alive" : "Launch %@").localized, appName))
|
||||
.accessibilityValue(accessibilityValue)
|
||||
.accessibilityHint(isLaunching
|
||||
? "Launch request in progress.".localized
|
||||
: "Double-tap to launch this app without enabling JIT.".localized)
|
||||
: (isHoldMode
|
||||
? "Double-tap to hold this app alive in the background.".localized
|
||||
: "Double-tap to launch this app without enabling JIT.".localized))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityRemoveTraits(.isStaticText)
|
||||
.accessibilityAction(named: Text("Launch App".localized)) {
|
||||
.accessibilityAction(named: Text((isHoldMode ? "Hold App Alive" : "Launch App").localized)) {
|
||||
guard !isLaunching else { return }
|
||||
launchAction()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@
|
|||
<string>_stikdebug._tcp</string>
|
||||
<string>_stikdebug._udp</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
|
|
|
|||
|
|
@ -22,12 +22,18 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
|
|||
var debugProxy: OpaquePointer?
|
||||
var remoteServer: OpaquePointer?
|
||||
var semaphore: DispatchSemaphore?
|
||||
|
||||
init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?) {
|
||||
/// When set (background keep-alive holds), the script loop stops once this
|
||||
/// token is cancelled. It is read only from the script's own thread — via
|
||||
/// `should_continue()` and `send_command()` — so there is no concurrent
|
||||
/// access to the debug proxy (which is not thread-safe).
|
||||
var cancellation: HoldToken?
|
||||
|
||||
init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?, cancellation: HoldToken? = nil) {
|
||||
self.pid = pid
|
||||
self.debugProxy = debugProxy
|
||||
self.remoteServer = remoteServer
|
||||
self.semaphore = semaphore
|
||||
self.cancellation = cancellation
|
||||
}
|
||||
|
||||
func runScript(path: URL, scriptName: String? = nil) throws {
|
||||
|
|
@ -53,11 +59,11 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
|
|||
self.context?.exception = JSValue(object: "Command should not be nil.", in: self.context!)
|
||||
return ""
|
||||
}
|
||||
if self.executionInterrupted {
|
||||
if self.executionInterrupted || self.cancellation?.isCancelled == true {
|
||||
self.context?.exception = JSValue(object: "Script execution is interrupted by StikDebug.", in: self.context!)
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
return handleJSContextSendDebugCommand(self.context, commandStr, self.debugProxy) ?? ""
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +84,17 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
|
|||
let hasTXMFunction: @convention(block) () -> Bool = {
|
||||
return ProcessInfo.processInfo.hasTXM
|
||||
}
|
||||
|
||||
|
||||
// Lets a hold script exit cleanly when the user stops the keep-alive
|
||||
// session. Returns false once interrupted or cancelled; scripts that
|
||||
// loop forever (e.g. universal.js hold mode) check this each iteration.
|
||||
// Always true for a normal one-shot JIT run (no cancellation token).
|
||||
let shouldContinueFunction: @convention(block) () -> Bool = {
|
||||
if self.executionInterrupted { return false }
|
||||
if self.cancellation?.isCancelled == true { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
context = JSContext()
|
||||
context?.setObject(hasTXMFunction, forKeyedSubscript: "hasTXM" as NSString)
|
||||
context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString)
|
||||
|
|
@ -86,7 +102,8 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable
|
|||
context?.setObject(prepareMemoryRegionFunction, forKeyedSubscript: "prepare_memory_region" as NSString)
|
||||
context?.setObject(takeScreenshotFunction, forKeyedSubscript: "take_screenshot" as NSString)
|
||||
context?.setObject(logFunction, forKeyedSubscript: "log" as NSString)
|
||||
|
||||
context?.setObject(shouldContinueFunction, forKeyedSubscript: "should_continue" as NSString)
|
||||
|
||||
context?.evaluateScript(scriptContent)
|
||||
if let semaphore {
|
||||
semaphore.signal()
|
||||
|
|
|
|||
|
|
@ -56,8 +56,15 @@ let attachResponse = send_command(`vAttach;${pid.toString(16)}`);
|
|||
log(`pid = ${pid}`);
|
||||
log(`attach_response = ${attachResponse}`);
|
||||
|
||||
// `should_continue()` is provided by the host. It returns false when a
|
||||
// background keep-alive hold is stopped, letting this loop exit cleanly instead
|
||||
// of holding the app forever. It is always true for a normal one-shot JIT run.
|
||||
function shouldKeepRunning() {
|
||||
return (typeof should_continue !== 'function') || should_continue();
|
||||
}
|
||||
|
||||
let totalBreakpoints = 0;
|
||||
while (!detached) {
|
||||
while (!detached && shouldKeepRunning()) {
|
||||
totalBreakpoints++;
|
||||
log(`Handling signal ${totalBreakpoints}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,13 +51,16 @@ final class BackgroundAliveManager: ObservableObject {
|
|||
/// - Parameter script: Optional JIT-script callback to run once (after
|
||||
/// attach, before the hold begins) so the app's assigned script executes
|
||||
/// in hold mode just like it does for a normal JIT run.
|
||||
func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil) {
|
||||
/// - Parameter token: Cancellation token for the hold. Pass the same token
|
||||
/// the `script` callback was built with so that stopping the session also
|
||||
/// stops the script's loop; defaults to a fresh token when there is no
|
||||
/// script.
|
||||
func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil, token: HoldToken = HoldToken()) {
|
||||
lock.lock()
|
||||
guard activeBundleID == nil else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let token = HoldToken()
|
||||
activeBundleID = bundleID
|
||||
self.token = token
|
||||
lock.unlock()
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
114
StikDebug/Services/KeepAliveLiveActivity.swift
Normal file
114
StikDebug/Services/KeepAliveLiveActivity.swift
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//
|
||||
// KeepAliveLiveActivity.swift
|
||||
// StikDebug
|
||||
//
|
||||
// App-side controller for the background keep-alive Live Activity. Starts a
|
||||
// Live Activity when a hold begins so it shows in the Dynamic Island (on
|
||||
// compatible iPhones) and on the Lock Screen, and ends it when the hold stops.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
enum KeepAliveLiveActivity {
|
||||
private static let lock = NSLock()
|
||||
/// Type-erased storage for the current `Activity`, so this enum has no
|
||||
/// availability requirement of its own and callers don't need to guard.
|
||||
private static var storage: Any?
|
||||
|
||||
/// Start (or replace) the keep-alive Live Activity for the given app.
|
||||
static func start(appName: String, bundleID: String) {
|
||||
#if canImport(ActivityKit)
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
startActivity(appName: appName, bundleID: bundleID)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Update the app name shown by the current Live Activity, if any.
|
||||
static func update(appName: String) {
|
||||
#if canImport(ActivityKit)
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
updateActivity(appName: appName)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// End the current keep-alive Live Activity, if any.
|
||||
static func end() {
|
||||
#if canImport(ActivityKit)
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
endActivity()
|
||||
#endif
|
||||
}
|
||||
|
||||
/// End any keep-alive activities left running by a previous launch (for
|
||||
/// example if the app was force-quit or crashed mid-hold). Call this once at
|
||||
/// startup, when no hold is active.
|
||||
static func endStaleActivities() {
|
||||
#if canImport(ActivityKit)
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
for activity in Activity<KeepAliveActivityAttributes>.activities {
|
||||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
lock.lock()
|
||||
storage = nil
|
||||
lock.unlock()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
@available(iOS 16.1, *)
|
||||
private static func startActivity(appName: String, bundleID: String) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
LogManager.shared.addInfoLog("Keep-alive Live Activity not started: Live Activities are disabled in Settings")
|
||||
return
|
||||
}
|
||||
|
||||
// Only one hold runs at a time, so make sure any stale activity is gone.
|
||||
endActivity()
|
||||
|
||||
let attributes = KeepAliveActivityAttributes(appName: appName, bundleID: bundleID)
|
||||
let state = KeepAliveActivityAttributes.ContentState(appName: appName)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
lock.lock()
|
||||
storage = activity
|
||||
lock.unlock()
|
||||
} catch {
|
||||
LogManager.shared.addErrorLog("Keep-alive Live Activity failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
private static func updateActivity(appName: String) {
|
||||
lock.lock()
|
||||
let activity = storage as? Activity<KeepAliveActivityAttributes>
|
||||
lock.unlock()
|
||||
guard let activity else { return }
|
||||
|
||||
let state = KeepAliveActivityAttributes.ContentState(appName: appName)
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
private static func endActivity() {
|
||||
lock.lock()
|
||||
let activity = storage as? Activity<KeepAliveActivityAttributes>
|
||||
storage = nil
|
||||
lock.unlock()
|
||||
guard let activity else { return }
|
||||
|
||||
Task {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -72,6 +72,43 @@ enum ScriptStore {
|
|||
assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager)
|
||||
}
|
||||
|
||||
/// The JIT script to run when holding an app alive in the background.
|
||||
///
|
||||
/// On a TXM device (iOS 26+) JIT is serviced by handling `brk #0xf00d`
|
||||
/// syscalls, so an app that is merely attached — with no script servicing
|
||||
/// those traps — hangs the moment it requests executable memory. That is
|
||||
/// why keep-alive "didn't work for any app": only apps whose name/bundle
|
||||
/// matched an assigned or auto script got a callback. Fall back to the
|
||||
/// bundled universal script so *any* app has its JIT traps serviced (and,
|
||||
/// for non-JIT apps, its debug connection actively held) while alive.
|
||||
///
|
||||
/// Returns `nil` on non-TXM devices, where JIT comes from `CS_DEBUGGED`
|
||||
/// alone and a plain attach is enough to hold the app.
|
||||
static func keepAliveScript(for bundleID: String, fileManager: FileManager = .default) -> (data: Data, name: String)? {
|
||||
guard ProcessInfo.processInfo.hasTXM else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let preferred = preferredScript(for: bundleID, fileManager: fileManager) {
|
||||
return preferred
|
||||
}
|
||||
|
||||
return bundledScript(resourceName: "universal", fileName: "universal.js")
|
||||
}
|
||||
|
||||
/// Reads a bundled script straight from the app bundle (not the user's
|
||||
/// scripts directory). Used for the keep-alive fallback so the current
|
||||
/// shipped script — which supports `should_continue()` cancellation — is
|
||||
/// always used, even for users whose documents copy predates it.
|
||||
private static func bundledScript(resourceName: String, fileName: String) -> (data: Data, name: String)? {
|
||||
guard let bundleURL = Bundle.main.url(forResource: resourceName, withExtension: "js"),
|
||||
let data = try? Data(contentsOf: bundleURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (data, fileName)
|
||||
}
|
||||
|
||||
static func favoriteAppName(
|
||||
for bundleID: String,
|
||||
defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,14 @@ struct HomeView: View {
|
|||
} else {
|
||||
startJITInBackground(bundleID: selectedBundle, displayName: selectedName)
|
||||
}
|
||||
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
||||
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }, onHoldApp: { selectedBundle, selectedName in
|
||||
// Selecting an app from the "Other" tab while keep-alive is enabled
|
||||
// holds it in the background (and shows the banner) instead of just
|
||||
// launching it — the same behavior as the JIT tab.
|
||||
bundleID = selectedBundle
|
||||
Haptics.medium()
|
||||
startKeepAlive(bundleID: selectedBundle, displayName: selectedName)
|
||||
})
|
||||
.overlay(alignment: .top) {
|
||||
if let activeApp = aliveManager.activeAppName {
|
||||
keepAliveBanner(appName: activeApp)
|
||||
|
|
@ -291,17 +298,22 @@ struct HomeView: View {
|
|||
)
|
||||
return
|
||||
}
|
||||
// Resolve the app's assigned JIT script the same way a normal JIT run
|
||||
// does, so the script also runs in hold mode instead of only attaching.
|
||||
// Share one cancellation token between the hold and the JIT script so
|
||||
// that stopping the session also stops the script's loop.
|
||||
let token = HoldToken()
|
||||
|
||||
// Resolve the JIT script to run in hold mode. On TXM devices this falls
|
||||
// back to the bundled universal script for apps without an assigned or
|
||||
// name-matched script, so keep-alive services JIT (and actively holds
|
||||
// the debug connection) for *any* app instead of only known ones.
|
||||
var script: DebugAppCallback? = nil
|
||||
if ProcessInfo.processInfo.hasTXM,
|
||||
let preferred = ScriptStore.preferredScript(for: bundleID) {
|
||||
script = getJsCallback(preferred.data, name: preferred.name)
|
||||
if let holdScript = ScriptStore.keepAliveScript(for: bundleID) {
|
||||
script = getJsCallback(holdScript.data, name: holdScript.name, cancellation: token)
|
||||
}
|
||||
|
||||
let startingMessage = String(format: "Keeping %@ alive in the background".localized, name)
|
||||
AccessibilityAnnouncer.announce(startingMessage)
|
||||
BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script)
|
||||
BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script, token: token)
|
||||
}
|
||||
|
||||
private func debugFeedbackView(_ feedback: DebugFeedback) -> some View {
|
||||
|
|
@ -325,12 +337,13 @@ struct HomeView: View {
|
|||
.accessibilityLabel(feedback.message)
|
||||
}
|
||||
|
||||
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
||||
private func getJsCallback(_ script: Data, name: String? = nil, cancellation: HoldToken? = nil) -> DebugAppCallback {
|
||||
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
|
||||
let model = RunJSViewModel(pid: Int(pid),
|
||||
debugProxy: debugProxyHandle,
|
||||
remoteServer: remoteServerHandle,
|
||||
semaphore: semaphore)
|
||||
semaphore: semaphore,
|
||||
cancellation: cancellation)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
scriptRunModel = model
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ struct InstalledAppsListView: View {
|
|||
let onSelectApp: (String, String) -> Void
|
||||
let showDoneButton: Bool
|
||||
let onImportPairingFile: (() -> Void)?
|
||||
/// Called when an app in the "Other" tab is tapped while background
|
||||
/// keep-alive is enabled. When set, tapping holds the app alive instead of
|
||||
/// launching it without a debugger.
|
||||
let onHoldApp: ((String, String) -> Void)?
|
||||
|
||||
private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard
|
||||
|
||||
|
|
@ -30,6 +34,7 @@ struct InstalledAppsListView: View {
|
|||
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
||||
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
|
||||
@AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:]
|
||||
@AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false
|
||||
|
||||
@State private var launchingBundles: Set<String> = []
|
||||
@State private var launchFeedback: LaunchFeedback?
|
||||
|
|
@ -45,11 +50,19 @@ struct InstalledAppsListView: View {
|
|||
init(
|
||||
onSelectApp: @escaping (String, String) -> Void,
|
||||
showDoneButton: Bool = true,
|
||||
onImportPairingFile: (() -> Void)? = nil
|
||||
onImportPairingFile: (() -> Void)? = nil,
|
||||
onHoldApp: ((String, String) -> Void)? = nil
|
||||
) {
|
||||
self.onSelectApp = onSelectApp
|
||||
self.showDoneButton = showDoneButton
|
||||
self.onImportPairingFile = onImportPairingFile
|
||||
self.onHoldApp = onHoldApp
|
||||
}
|
||||
|
||||
/// Whether tapping an app in the "Other" tab should hold it alive rather
|
||||
/// than launch it without a debugger.
|
||||
private var holdModeEnabled: Bool {
|
||||
keepAppAliveBackground && onHoldApp != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -283,17 +296,29 @@ struct InstalledAppsListView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var launchSectionFooter: some View {
|
||||
if holdModeEnabled {
|
||||
Text("Keep-Alive is on: tapping an app holds it alive in the background instead of just launching it.".localized)
|
||||
}
|
||||
}
|
||||
|
||||
private func launchAppSection(snapshot: LaunchAppListSnapshot) -> some View {
|
||||
Section("All Apps".localized) {
|
||||
Section(header: Text("All Apps".localized), footer: launchSectionFooter) {
|
||||
ForEach(snapshot.apps) { app in
|
||||
let isPinned = pinnedSystemApps.contains(app.bundleID)
|
||||
|
||||
LaunchAppRow(
|
||||
bundleID: app.bundleID,
|
||||
appName: app.name,
|
||||
isLaunching: launchingBundles.contains(app.bundleID)
|
||||
isLaunching: launchingBundles.contains(app.bundleID),
|
||||
isHoldMode: holdModeEnabled
|
||||
) {
|
||||
startLaunching(bundleID: app.bundleID, appName: app.name)
|
||||
if holdModeEnabled {
|
||||
onHoldApp?(app.bundleID, app.name)
|
||||
} else {
|
||||
startLaunching(bundleID: app.bundleID, appName: app.name)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPinned {
|
||||
|
|
|
|||
29
StikDebugShared/KeepAliveActivityAttributes.swift
Normal file
29
StikDebugShared/KeepAliveActivityAttributes.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// KeepAliveActivityAttributes.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Shared between the app (which starts/updates/ends the activity) and the
|
||||
// widget extension (which renders it in the Dynamic Island / on the Lock
|
||||
// Screen). Keep this file in the StikDebugShared group so both targets compile
|
||||
// the same type.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
struct KeepAliveActivityAttributes: ActivityAttributes {
|
||||
/// The parts of the activity that change while it is live.
|
||||
struct ContentState: Codable, Hashable {
|
||||
/// The display name of the app currently being held alive.
|
||||
var appName: String
|
||||
}
|
||||
|
||||
/// The app being held alive when the activity started.
|
||||
var appName: String
|
||||
/// The bundle identifier of that app.
|
||||
var bundleID: String
|
||||
}
|
||||
#endif
|
||||
11
StikDebugWidgets/Info.plist
Normal file
11
StikDebugWidgets/Info.plist
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
88
StikDebugWidgets/KeepAliveLiveActivityWidget.swift
Normal file
88
StikDebugWidgets/KeepAliveLiveActivityWidget.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//
|
||||
// KeepAliveLiveActivityWidget.swift
|
||||
// StikDebugWidgets
|
||||
//
|
||||
// Live Activity presentation for the background keep-alive session. Shows the
|
||||
// held app in the Dynamic Island (compact, expanded, and minimal) and on the
|
||||
// Lock Screen.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
struct KeepAliveLiveActivityWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: KeepAliveActivityAttributes.self) { context in
|
||||
// Lock Screen / banner presentation.
|
||||
KeepAliveLockScreenView(appName: context.state.appName)
|
||||
.activityBackgroundTint(Color.black.opacity(0.55))
|
||||
.activitySystemActionForegroundColor(.green)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text("JIT")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.appName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text("Held alive in the background")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} compactTrailing: {
|
||||
Text(context.state.appName)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
} minimal: {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.keylineTint(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
private struct KeepAliveLockScreenView: View {
|
||||
let appName: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.title)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Keeping \(appName) alive")
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text("Held in the background")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
17
StikDebugWidgets/StikDebugWidgetsBundle.swift
Normal file
17
StikDebugWidgets/StikDebugWidgetsBundle.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// StikDebugWidgetsBundle.swift
|
||||
// StikDebugWidgets
|
||||
//
|
||||
// Widget extension bundle. Hosts the keep-alive Live Activity so it can be
|
||||
// rendered in the Dynamic Island and on the Lock Screen.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct StikDebugWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
KeepAliveLiveActivityWidget()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue