From d2f09bea32489c7e7521b4e1e9a104a1167f49c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 05:38:41 +0000 Subject: [PATCH 1/2] Honor "Hidden" points of interest for bus stops and declutter at low zoom Bus stop markers are our own Marker overlay drawn from Overpass data, not Apple's native POI layer, so setting Points of Interest to "Hidden" (which only sets MapStyle's pointsOfInterest category filter) had no effect on them -- they kept showing regardless of the setting. Separately, a bus route can have dozens of stops close together; zoomed out they overlap into an unreadable stack of pins (see reported screenshot: a wall of stacked bus icons across a multi-city view). Fixes: - Gate the bus stop markers on mapPointsOfInterestMode != .hidden so "Hidden" actually hides them. - Track the map's visible latitude span via onMapCameraChange and thin markers through the existing dedupedBusStops spatial merge, using a spacing that scales with the visible span -- full detail at close zoom, progressively fewer markers as the map zooms out. The full-fidelity routeBusStops list (used for dwell pacing during playback) is untouched; only the on-screen marker set is thinned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Views/MapSelectionView.swift | 35 ++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 56f5a10..4364f83 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -1436,6 +1436,7 @@ struct LocationSimulationView: View { @State private var routePolyline: MKPolyline? @State private var routePlaybackSamples: [RoutePlaybackSample] = [] @State private var routeBusStops: [CLLocationCoordinate2D] = [] + @State private var visibleLatitudeDelta: CLLocationDegrees = 0.05 @State private var routePlaybackCoordinate: CLLocationCoordinate2D? @State private var simulatedCoordinate: CLLocationCoordinate2D? @State private var routeRequestID = UUID() @@ -1494,6 +1495,29 @@ struct LocationSimulationView: View { } } + /// Bus stop markers to actually draw on the map. Two adjustments on top of + /// the raw fetched `routeBusStops`: + /// + /// - Honors "Points of Interest: Hidden" — the stops are drawn as our own + /// Marker overlay, not Apple's native POI layer, so Apple's + /// `pointsOfInterest` style option (set from the same picker) has no + /// effect on them; that had to be handled here explicitly. + /// - Thins markers as the map zooms out, since a bus route easily has + /// dozens of stops that overlap into an unreadable stack of pins at a + /// city-wide zoom. Spacing scales with the visible latitude span so + /// roughly the same number of markers are visible on screen at any + /// zoom level; the full-fidelity `routeBusStops` list (used for dwell + /// pacing during playback) is untouched. + private var displayedBusStops: [CLLocationCoordinate2D] { + guard speedProfile == .bus, mapPointsOfInterestMode != .hidden else { return [] } + let visibleSpanMeters = visibleLatitudeDelta * 111_320 + let minimumSpacing = max( + SpeedProfileSettings.busStopSnapRadius, + visibleSpanMeters / 25 + ) + return dedupedBusStops(routeBusStops, radius: minimumSpacing) + } + private var speedProfile: SpeedProfile { SpeedProfile(rawValue: speedProfileRawValue) ?? .driving } @@ -1648,11 +1672,9 @@ struct LocationSimulationView: View { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } - if speedProfile == .bus { - ForEach(Array(routeBusStops.enumerated()), id: \.offset) { _, stop in - Marker("Bus Stop", systemImage: "bus.fill", coordinate: stop) - .tint(.orange) - } + ForEach(Array(displayedBusStops.enumerated()), id: \.offset) { _, stop in + Marker("Bus Stop", systemImage: "bus.fill", coordinate: stop) + .tint(.orange) } if let routeStartCoordinate { Marker("Start", coordinate: routeStartCoordinate) @@ -1680,6 +1702,9 @@ struct LocationSimulationView: View { .mapControls { MapCompass() } + .onMapCameraChange(frequency: .onEnd) { context in + visibleLatitudeDelta = context.region.span.latitudeDelta + } } .ignoresSafeArea() .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in From 42951c6e105b8ba4451f781d770e6196f5403e2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 06:01:15 +0000 Subject: [PATCH 2/2] Run the JIT script in hold-alive mode so attached apps get working JIT The experimental "Hold App Alive in Background" mode attached the debugger (setting CS_DEBUGGED) but never ran the app's assigned JIT script. On TXM / iOS 26+ devices JIT is not enabled merely by attaching: the app requests executable memory at runtime via `brk #0xf00d` syscalls that the debugger must service, which is exactly what the JIT script does in its breakpoint loop. Without the script those syscalls went unhandled, so the app trapped and hung (or JIT silently failed) on its first JIT call -- "the JIT script doesn't run when attaching to an app." Thread the same JIT-script callback the normal debug path uses through the hold flow: - HomeView.startKeepAlive resolves the app's preferred script (gated on hasTXM, exactly like startJITInBackground) and passes it down. - BackgroundAliveManager.start and JITEnableContext.keepAppAlive forward it to holdDebugSession. - holdDebugSession, when a script is present, runs it (the script does its own vAttach + JIT enable + continue and services the app's JIT syscalls for as long as the app uses JIT, keeping the app alive under the debugger). When absent it keeps the previous attach-and-hold behavior for older devices where CS_DEBUGGED alone enables JIT. The normal (non-hold) JIT path is unchanged. Known limitation: for an app whose script runs continuously, the hold ends when the app is closed (matching the normal flow, which likewise cannot interrupt a running script); the Stop button fully applies to the no-script hold path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Device/JITEnableContext.swift | 71 +++++++++++++++---- .../Services/BackgroundAliveManager.swift | 6 +- StikDebug/Views/HomeView.swift | 10 ++- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index a63c5e7..c1db201 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -642,8 +642,9 @@ final class JITEnableContext { } } - /// Launches an app, attaches the debugger, and *holds* the session open so the - /// app keeps running in the background. + /// Launches an app, attaches the debugger, runs the app's assigned JIT + /// script (if any), and *holds* the session open so the app keeps running + /// in the background. /// /// A debugger-owned process is not suspended by iOS when it moves to the /// background (the same reason an app run from Xcode keeps executing while @@ -652,10 +653,14 @@ final class JITEnableContext { /// background jetsam kill. The hold runs until `cancellation` is set, then we /// interrupt and detach so the app continues running on its own. /// + /// - Parameter script: The same JIT-script callback the normal debug path + /// uses. When present it runs once (doing its own attach + JIT enable + + /// continue) before the hold begins, so scripts work in hold mode too; + /// when `nil` the app is simply attached and continued. /// - Note: This keeps StikDebug's connection to *this* app alive; it cannot /// revive an app iOS has already killed. Requires StikDebug itself to stay /// running (see `DebugKeepAliveLease`). - func keepAppAlive(withBundleID bundleID: String, cancellation: HoldToken, logger: LogFunc?) -> Bool { + func keepAppAlive(withBundleID bundleID: String, script: DebugAppCallback?, cancellation: HoldToken, logger: LogFunc?) -> Bool { do { try withConnectedDebugSession { remoteServer, debugProxy in let pid = try withProcessControl(remoteServer: remoteServer) { processControl in @@ -677,7 +682,14 @@ final class JITEnableContext { return Int32(truncatingIfNeeded: pid) } - try holdDebugSession(pid: pid, debugProxy: debugProxy, logger: logger, cancellation: cancellation) + try holdDebugSession( + pid: pid, + debugProxy: debugProxy, + remoteServer: remoteServer, + script: script, + logger: logger, + cancellation: cancellation + ) } emitLog("Keep-alive session ended for \(bundleID)", logger: logger) @@ -688,7 +700,14 @@ final class JITEnableContext { } } - private func holdDebugSession(pid: Int32, debugProxy: OpaquePointer, logger: LogFunc?, cancellation: HoldToken) throws { + private func holdDebugSession( + pid: Int32, + debugProxy: OpaquePointer, + remoteServer: OpaquePointer, + script: DebugAppCallback?, + logger: LogFunc?, + cancellation: HoldToken + ) throws { debug_proxy_send_ack(debugProxy) debug_proxy_send_ack(debugProxy) @@ -701,23 +720,47 @@ final class JITEnableContext { debug_proxy_set_ack_mode(debugProxy, 0) - let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" - let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" - emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) - // Keep the RSD/lockdown tunnel trusted for the whole hold. let heartbeat = try? connectHeartbeatKeepAlive(logger: logger) try? heartbeat?.start() defer { heartbeat?.stop() } - // Continue the (suspended-launched) process so it runs. Sent raw and not - // awaited: a debugger-owned, running process is not stopped by iOS when - // backgrounded, so there are no stop replies to drain — we just hold the - // connection open. Polling a flag (rather than blocking on a socket read) - // keeps this single-threaded and cleanly cancellable. + if let script { + // TXM / iOS 26+ JIT: the app requests executable memory at runtime via + // `brk #0xf00d` syscalls that the debugger must service. That is exactly + // what the JIT script does — it attaches (`vAttach`, enabling JIT), + // then loops handling those breakpoints for as long as the app uses + // JIT, which also keeps the app alive under the debugger the whole + // time. Merely attaching without running the script (the old hold + // behavior) left those syscalls unhandled, so the app trapped and hung + // on its first JIT call — the bug this fixes. + // + // The script issues its own attach/continue via `send_command`, so we + // must NOT send `vAttach` here (that would double-attach). It runs + // until the app detaches or exits; the semaphore blocks until then. + // StikDebug itself is held alive across this by the caller's + // `DebugKeepAliveLease`. + emitLog("Keep-alive: running JIT script (services JIT and holds the app)", logger: logger) + let semaphore = DispatchSemaphore(value: 0) + script(pid, debugProxy, remoteServer, semaphore) + semaphore.wait() + emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger) + return + } + + // No script (older devices where JIT is enabled simply by CS_DEBUGGED): + // attach to set CS_DEBUGGED, then continue so the app runs. Sent raw and + // not awaited: a debugger-owned, running process is not stopped by iOS + // when backgrounded, so there are no stop replies to drain — we just hold + // the connection open until the user stops it. + let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" + let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) sendContinue(debugProxy) emitLog("Keep-alive: app is running and held under the debugger", logger: logger) + // Polling a flag (rather than blocking on a socket read) keeps this + // single-threaded and cleanly cancellable. while !cancellation.isCancelled { Thread.sleep(forTimeInterval: 0.5) } diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index eab079b..3c0ae8f 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -48,7 +48,10 @@ final class BackgroundAliveManager: ObservableObject { return activeBundleID != nil } - func start(bundleID: String, displayName: String?) { + /// - 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) { lock.lock() guard activeBundleID == nil else { lock.unlock() @@ -76,6 +79,7 @@ final class BackgroundAliveManager: ObservableObject { let succeeded = JITEnableContext.shared.keepAppAlive( withBundleID: bundleID, + script: script, cancellation: token, logger: logger ) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index c940409..f833630 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -291,9 +291,17 @@ 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. + var script: DebugAppCallback? = nil + if ProcessInfo.processInfo.hasTXM, + let preferred = ScriptStore.preferredScript(for: bundleID) { + script = getJsCallback(preferred.data, name: preferred.name) + } + let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) AccessibilityAnnouncer.announce(startingMessage) - BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script) } private func debugFeedbackView(_ feedback: DebugFeedback) -> some View {