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 { 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