Merge pull request #6 from dizzafizza/claude/fork-code-review-33fkom

Honor "Hidden" points of interest for bus stops and declutter at low zoom, fix JIT scripts
This commit is contained in:
James 2026-07-14 23:42:20 -07:00 • committed by GitHub
commit 5c3c37a1e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 101 additions and 21 deletions

View file

@ -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) ?? "<nil>"
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) ?? "<nil>"
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)
}

View file

@ -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
)

View file

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

View file

@ -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,12 +1672,10 @@ struct LocationSimulationView: View {
MapPolyline(routePolyline)
.stroke(.blue.opacity(0.8), lineWidth: 5)
}
if speedProfile == .bus {
ForEach(Array(routeBusStops.enumerated()), id: \.offset) { _, stop in
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)
.tint(.green)
@ -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