diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index 48c56d1c..da8f2efc 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -24,7 +24,9 @@ jobs: - name: 2. Configure Xcode Environment uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.0.1' + xcode-version: '26.6.0' + + - name: 3. Compile Project Archive run: | @@ -42,6 +44,7 @@ jobs: SWIFT_OPTIMIZATION_LEVEL="-Onone" \ IPHONEOS_DEPLOYMENT_TARGET=17.4 + - name: 4. Package .app into .ipa run: | cp -R build/StikDebug.xcarchive/Products/Applications/StikDebug.app . diff --git a/CODE_REVIEW_REPORT_BY_FABLE_5.md b/CODE_REVIEW_REPORT_BY_FABLE_5.md new file mode 100644 index 00000000..a540e8be --- /dev/null +++ b/CODE_REVIEW_REPORT_BY_FABLE_5.md @@ -0,0 +1,151 @@ +# Fork Code-Review Report — StikDebug-PLUS + +**Date:** 2026-07-14 +**Branch reviewed:** `claude/fork-code-review-33fkom` +**Scope:** All fork-specific changes since divergence from upstream StikDebug (commit `c5b23e2`). + +## Summary + +The fork adds four feature areas on top of upstream StikDebug: a location-simulator +overhaul (speed profiles, bus stops, map styles, long-route performance), a +cellular/Wi-Fi network path monitor with tunnel auto-reconnect, background +keep-alive improvements for debug sessions, and an experimental "hold app alive +in background" mode. 15 files changed, ~1,400 lines added. + +I reviewed the full diff against upstream, cross-checked every new FFI call +against `idevice.h`, traced the state machines in the changed managers, and +confirmed build status via GitHub Actions history. **Two genuine defects were +found and fixed; both are pushed to the review branch.** No crashes, compile +errors, or logic bugs remain in the reviewed code. + +--- + +## Defects fixed + +### 1. Always-failing CI workflow — `.github/workflows/swift.yml` (removed) + +**Severity:** Medium (broken CI signal on every push/PR) + +The workflow ran `swift build` / `swift test`, which require a `Package.swift`. +This repository is an Xcode app project with no Swift package, so the job failed +with `error: Could not find Package.swift` on **every run since it was added**, +marking each push and PR with a red ❌ check. + +The existing **Build Debug IPA** workflow already compiles the project with +`xcodebuild` on the same `push`/`pull_request` triggers and passes green, so +build validation is fully covered without it. + +- **Fix:** Deleted `swift.yml` (commit `7b1df35`). +- **Evidence:** GitHub Actions run `29341384965` (job `87113733956`) — + `Could not find Package.swift in this directory or any of its parent directories`. + +### 2. Long rural road segments dropped from the speed index — `MapSelectionView.swift` (fixed) + +**Severity:** Low (accuracy regression on long routes; no crash) + +`RouteSpeedIndex` bucketed each OSM way segment into ~440 m grid cells, but +skipped any segment whose bounding box spanned more than **8 cells (~3.5 km)**: + +```swift +guard maxX - minX <= 8, maxY - minY <= 8 else { continue } +``` + +Sparsely-noded rural highways can legitimately run tens of km between OSM +geometry nodes. Those segments were silently dropped from the index, so those +stretches lost their real speed limit and fell back to average-route pacing — +a behavior regression versus upstream, whose (slower) implementation scanned +every segment without this cap. + +- **Fix:** Raised the guard to **128 cells (~57 km at the equator)** (commit + `e11a606`). This still catches genuinely broken data such as antimeridian + jumps (which span ~90,000 cells) while keeping realistic long segments + indexed. Per-segment registration stays bounded, and the `nearestWayThreshold` + (40 m) distance filter means indexing extra cells never produces false matches. + +--- + +## Areas verified as correct (no change needed) + +### Build / compilation +- **Build Debug IPA is green** on the fork tip (`346bff6`), confirming the app + compiles and that the `xcode-version: 26.6.0` pin resolves on the runner. +- `packet.count` is correctly converted to `UInt` for the `uintptr_t` + `debug_proxy_send_raw` parameter; `fetchRouteSpeedContext` returns a + `RouteSpeedContext` on all paths (both were prior build-fix commits and hold up). + +### Experimental "hold app alive" / JIT keep-alive (`JITEnableContext`, `BackgroundAliveManager`, `DebugKeepAliveLease`) +- Every FFI call matches `idevice.h`: `process_control_launch_app` argument + order and `bool` flags, `process_control_disable_memory_limit`, and + `debug_proxy_send_raw(handle, ptr, uintptr_t)`. +- The hand-built RSP continue packet `$c#63` has the correct payload and + checksum (`0x63 % 256` → `63`), and is valid in no-ack mode. +- The hold sequence mirrors the proven `debugApp` path: drain acks → + `QStartNoAckMode` → disable ack mode → `vAttach` → continue → poll for + cancellation → interrupt (`0x03`) → detach (`D`). +- `HoldToken` is lock-guarded; `BackgroundAliveManager` balances start/stop and + its `stillCurrent` check prevents a late-finishing session from clobbering a + newer one. `DebugKeepAliveLease()` auto-activates in `init`, so the bare + construction in both `BackgroundAliveManager` and `HomeView` is correct. +- Background-task renewal re-checks `isActive` before re-arming, so an expiring + UIKit assertion no longer tears down long sessions. + +### Background audio / location (`BackgroundAudioManager`, `BackgroundLocationManager`) +- The new `force:` (session) vs. unforced (user-toggle) activity counters are + balanced on every start/stop path and never go negative (`max(… - 1, 0)`). +- `refreshRunningState()` correctly honors forced holds regardless of the user's + keep-alive toggles, and `refreshFromSettings()` re-evaluates on toggle change. +- The non-zero silent-audio fill is inaudible (~-80 dBFS, DC-free) and guarded + by `!format.isInterleaved` with the correct channel/frame iteration; the + interleaved branch is an unreachable fallback on the standard mixer format. + +### Network path monitor & tunnel reconnect (`NetworkPathMonitor`, `TunnelManager`) +- Path state is lock-guarded; change notifications are deduplicated by signature. +- Tunnel retry (3 attempts, 1s→2s backoff) correctly short-circuits on permanent + errors (invalid/expired/missing pairing `-9/-17`, bad target IP `-18`, parse + failures) instead of pointlessly retrying user-actionable failures. +- The reconnect observer is registered on the main queue, matching `start()`'s + main-thread expectation; the debounced work item cancels prior pending work. + +### Location simulator — speed profiles, bus stops, map styles (`MapSelectionView`) +- The Overpass query moved from a bounding-box GET to a corridor `around` POST; + the body is percent-encoded with a form-safe character set and the client + timeout (30 s) exceeds the server timeout (25 s). +- `OverpassResponse.Element` decodes both way `geometry` and node `lat`/`lon`, + which `out tags geom;` emits for nodes — bus-stop parsing is sound. +- Bus-stop detection covers both the legacy `highway=bus_stop` and the modern + `public_transport=platform/stop_position` + `bus=yes` schemas. +- Route state (`routeBusStops`, `isImportedRoute`, `lastFallbackSpeed`, + `routeRequestID`) is reset consistently across import / clear / reset / refresh, + and every async prefetch guards on `routeRequestID` before applying results. +- Profile switching re-plans directions only when walking-vs-driving changes and + the route is searched (not imported); otherwise it rebuilds pacing in place. +- All SwiftUI/MapKit symbols are valid for the iOS 17.4 deployment target + (`MapStyle`, `PointOfInterestCategories`, `Marker`, `.mapStyle`). + +--- + +## Minor observations (intentional trade-offs — not changed) + +- **Bus ETA** in the route summary uses MapKit's automobile travel time and does + not add the per-stop dwell time, so a bus route's ETA is slightly optimistic. +- **Bus stop dwell** (12 s) is applied to the sample nearest the stop (≤25 m), + so playback pauses just before arriving rather than exactly at the stop. +- **`build_ipa.yml`** has two stray blank lines from an earlier edit; harmless + (YAML ignores them). +- **Bus-stop cell lookup** assumes non-polar latitudes (documented); above ~80°N + a 90 m search radius could exceed a longitude cell. Not reachable in practice. +- **`keepAppAlive`** blocks a global-queue thread for the hold duration and opens + multiple concurrent debug tunnels; acceptable for an explicitly experimental + feature, but worth revisiting if it graduates out of experimental. + +--- + +## Commits on the review branch + +| Commit | Change | +|--------|--------| +| `7b1df35` | Remove Swift Package CI workflow that can never pass | +| `e11a606` | Index long rural road segments instead of dropping them | + +Both are pushed to `origin/claude/fork-code-review-33fkom`. The Build Debug IPA +workflow will validate compilation on the branch tip. diff --git a/README.md b/README.md index 4f27be86..d9edf2a2 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ StikDebug enables **JIT** for sideloaded apps on iOS 17.4+ without needing a com **Troubleshooting** - "Connection dropped" or loopback errors → Check iOS version compatibility / beta warnings. -- Heartbeat errors → Ensure that the VPN is on and that you are connecected to Wi-Fi. It may be a pairing file issue. +- Heartbeat errors → Ensure that LocalDevVPN is on. Any network works (Wi-Fi **or** cellular); the tunnel rides the VPN, so cellular-only is supported. It may also be a pairing file issue. +- Session drops after switching to another app → Keep the background keep-alive enabled (Settings → Background Keep-Alive). While a JIT/debug session is active, StikDebug plays inaudible audio and holds a background assertion so the session survives being switched out of the app instead of timing out. - Pairing file issues → Replace file with device unlocked & trusted. - Still stuck? Join the [Discord](https://discord.gg/ZnNcrRT3M8) with logs/screenshots. diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index ffb94ac1..4ff12d50 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -12,6 +12,7 @@ enum AppBootstrapper { registerDefaultSettings() startConfiguredKeepAliveServices() applyDocumentPickerCopyWorkaround() + NetworkPathMonitor.shared.start() } private static func registerDefaultSettings() { @@ -23,7 +24,8 @@ enum AppBootstrapper { UserDefaults.Keys.txmOverride: false, UserDefaults.Keys.confirmExternalJITRequests: true, "keepAliveAudio": true, - "keepAliveLocation": true + "keepAliveLocation": true, + "keepAppAliveBackground": false ]) } diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index 656004b8..b654e577 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -642,6 +642,203 @@ final class JITEnableContext { } } + /// 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 + /// backgrounded). Attaching also sets `CS_DEBUGGED`, so JIT is enabled too. + /// We additionally lift the process memory limit to reduce the chance of a + /// 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, script: DebugAppCallback?, cancellation: HoldToken, logger: LogFunc?) -> Bool { + do { + try withConnectedDebugSession { remoteServer, debugProxy in + let pid = try withProcessControl(remoteServer: remoteServer) { processControl in + var pid: UInt64 = 0 + let ffiError = bundleID.withCString { bundleID in + process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, true, false, &pid) + } + if let ffiError { + throw error(from: ffiError, fallback: "Failed to launch app") + } + + // Best-effort: lift the memory limit so iOS is less likely to + // jetsam-kill the app while it is in the background. + if let limitError = process_control_disable_memory_limit(processControl, pid) { + let disableError = error(from: limitError, fallback: "Failed to disable memory limit") + emitLog("Keep-alive: could not disable memory limit: \(disableError.localizedDescription)", logger: logger) + } + + return Int32(truncatingIfNeeded: pid) + } + + try holdDebugSession( + pid: pid, + debugProxy: debugProxy, + remoteServer: remoteServer, + script: script, + logger: logger, + cancellation: cancellation + ) + } + + emitLog("Keep-alive session ended for \(bundleID)", logger: logger) + return true + } catch { + emitLog("Keep-alive session failed: \(error.localizedDescription)", logger: logger) + return false + } + } + + 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) + + do { + let response = try sendDebugCommand("QStartNoAckMode", debugProxy: debugProxy) ?? "" + emitLog("Keep-alive QStartNoAckMode = \(response)", logger: logger) + } catch { + emitLog(error.localizedDescription, logger: logger) + } + + debug_proxy_set_ack_mode(debugProxy, 0) + + // Keep the RSD/lockdown tunnel trusted for the whole hold. + let heartbeat = try? connectHeartbeatKeepAlive(logger: logger) + try? heartbeat?.start() + defer { heartbeat?.stop() } + + 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) + + // 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 + } + + // No script: hold the app by attaching and *actively servicing* the + // debug connection. A debugger-owned process is not suspended by iOS in + // the background — but a debugged process also stops on every signal or + // exception, and if we never answer those stops it stays halted on the + // first one, can't service its own run loop, and iOS's watchdog kills it + // (which is what looked like the app "crashing" / keep-alive not + // working). So we continue on each stop, forwarding real signals so the + // app behaves exactly as if it were not being debugged, and keep going + // until the app exits or the user stops the hold. + let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" + var stopReply = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Keep-alive attach response: \(stopReply)", logger: logger) + emitLog("Keep-alive: app is running and held under the debugger", logger: logger) + + // Cancellation is re-checked after each stop. While the app runs, the + // continue blocks reading the socket — single-threaded, so there is no + // concurrent access to the (non-thread-safe) debug proxy. The hold also + // ends by itself when the app exits (the natural end when the user + // closes it). + while !cancellation.isCancelled { + // A process-exit reply ("W"/"X") means the app is gone — nothing + // left to hold or detach. + if stopReply.hasPrefix("W") || stopReply.hasPrefix("X") { + emitLog("Keep-alive: app exited (\(stopReply)); ending hold", logger: logger) + return + } + + do { + stopReply = try sendDebugCommand(holdContinueCommand(forStopReply: stopReply), debugProxy: debugProxy) ?? "" + } catch { + // The connection dropped — the app was killed or the tunnel + // died. Stop holding; there is nothing to detach from. + emitLog("Keep-alive: debug connection ended (\(error.localizedDescription)); ending hold", logger: logger) + return + } + + if stopReply.isEmpty { + emitLog("Keep-alive: empty stop reply; ending hold", logger: logger) + return + } + } + + // Cancelled by the user: interrupt, then detach, so the app keeps + // running on its own afterward. + 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) + } + } + + /// The continue command used to resume a held app after it stops. Real + /// signals are forwarded (`C`) so the app behaves as if it were not + /// under a debugger; debugger artifacts — the SIGSTOP from attaching and + /// SIGTRAP traps — are swallowed with a plain continue so they don't + /// immediately re-stop or re-trap the app. + private func holdContinueCommand(forStopReply reply: String) -> String { + guard reply.hasPrefix("T"), reply.count >= 3, + let signal = UInt8(reply.dropFirst().prefix(2), radix: 16) else { + return "c" + } + + switch Int32(signal) { + case 0, SIGSTOP, SIGTRAP: + return "c" + default: + return String(format: "C%02x", signal) + } + } + func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { do { try ensureTunnel() diff --git a/StikDebug/Features/InstalledApps/InstalledAppRows.swift b/StikDebug/Features/InstalledApps/InstalledAppRows.swift index 8820fb7c..07a52aff 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/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 0d5ef1ea..df98a489 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,17 +59,17 @@ 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) ?? "" } let logFunction: @convention(block) (String) -> Void = { logStr in DispatchQueue.main.async { - self.logs.append(logStr) + self.appendLog(logStr) } } @@ -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() @@ -94,13 +111,27 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable DispatchQueue.main.async { if let exception = self.context?.exception { - self.logs.append(exception.debugDescription) + self.appendLog(exception.debugDescription) } - self.logs.append("Script Execution Completed") - self.logs.append("You are safe to close this window.") + self.appendLog("Script Execution Completed") + self.appendLog("You are safe to close this window.") } } - + + /// Appends a log line, keeping the buffer bounded. A hold-mode script (e.g. + /// universal.js) can run indefinitely and log on every iteration; without a + /// cap the `@Published` array — and the SwiftUI list bound to it — grows + /// without bound and eventually exhausts memory. Must run on the main thread. + private func appendLog(_ entry: String) { + logs.append(entry) + if logs.count > Self.maxLogEntries + Self.logTrimSlack { + logs.removeFirst(logs.count - Self.maxLogEntries) + } + } + + private static let maxLogEntries = 1000 + private static let logTrimSlack = 256 + private func captureScreenshot(named preferredName: String?) -> String { if executionInterrupted { raiseException("Script execution is interrupted by StikDebug.") diff --git a/StikDebug/Scripts/universal.js b/StikDebug/Scripts/universal.js index 26cf98d3..6c33326f 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 new file mode 100644 index 00000000..5236301b --- /dev/null +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -0,0 +1,122 @@ +// +// BackgroundAliveManager.swift +// StikDebug +// + +import Foundation +import UIKit + +/// Thread-safe cancellation flag for a held debug session. +final class HoldToken { + private let lock = NSLock() + private var cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } +} + +/// Owns an active "keep this app alive in the background" session. +/// +/// It launches the target app, holds the debugger attached to it (which stops +/// iOS from suspending it in the background), and keeps StikDebug itself alive +/// (`DebugKeepAliveLease`) for as long as the hold is running. Only one app can +/// be held at a time. This is experimental — see `JITEnableContext.keepAppAlive`. +final class BackgroundAliveManager: ObservableObject { + static let shared = BackgroundAliveManager() + + @Published private(set) var activeAppName: String? + + private let lock = NSLock() + private var token: HoldToken? + private var lease: DebugKeepAliveLease? + private var activeBundleID: String? + + private init() {} + + var isActive: Bool { + lock.lock() + defer { lock.unlock() } + return activeBundleID != nil + } + + /// - 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. + /// - 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 + } + activeBundleID = bundleID + self.token = token + lock.unlock() + + // Create the keep-alive lease outside the lock (it touches the main thread). + let lease = DebugKeepAliveLease() + lock.lock() + self.lease = lease + lock.unlock() + + let name = displayName ?? bundleID + DispatchQueue.main.async { self.activeAppName = name } + LogManager.shared.addInfoLog("Starting background keep-alive for \(name)") + + DispatchQueue.global(qos: .userInitiated).async { + let logger: LogFunc = { message in + if let message { LogManager.shared.addInfoLog(message) } + } + + let succeeded = JITEnableContext.shared.keepAppAlive( + withBundleID: bundleID, + script: script, + cancellation: token, + logger: logger + ) + + self.lock.lock() + let stillCurrent = self.activeBundleID == bundleID && self.token === token + if stillCurrent { + self.activeBundleID = nil + self.token = nil + self.lease = nil + } + self.lock.unlock() + + lease.invalidate() + + DispatchQueue.main.async { + if stillCurrent { + self.activeAppName = nil + } + if !succeeded { + showAlert( + title: "Keep-Alive Ended", + message: "The background keep-alive session for \(name) could not start or ended early. Make sure the app is installed and that LocalDevVPN and the pairing file are active.", + showOk: true + ) + } + } + } + } + + func stop() { + lock.lock() + let token = self.token + lock.unlock() + token?.cancel() + } +} diff --git a/StikDebug/Services/BackgroundAudioManager.swift b/StikDebug/Services/BackgroundAudioManager.swift index c19f22ba..181016c8 100644 --- a/StikDebug/Services/BackgroundAudioManager.swift +++ b/StikDebug/Services/BackgroundAudioManager.swift @@ -13,6 +13,7 @@ final class BackgroundAudioManager { private var isRunning = false private var persistentEnabled = false private var activityCount = 0 + private var forcedActivityCount = 0 private var healthCheckTimer: Timer? private init() { @@ -40,18 +41,33 @@ final class BackgroundAudioManager { refreshRunningState() } - func requestStart() { - activityCount += 1 + /// Request that the app be kept alive by silent audio. + /// + /// Pass `force: true` for an active debug session that must keep running + /// regardless of the user's "Silent Audio" toggle — forced holds bypass the + /// setting so a session survives being switched out of the app. + func requestStart(force: Bool = false) { + if force { + forcedActivityCount += 1 + } else { + activityCount += 1 + } refreshRunningState() } - func requestStop() { - activityCount = max(activityCount - 1, 0) + func requestStop(force: Bool = false) { + if force { + forcedActivityCount = max(forcedActivityCount - 1, 0) + } else { + activityCount = max(activityCount - 1, 0) + } refreshRunningState() } private func refreshRunningState() { - let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")) + let shouldRun = persistentEnabled + || forcedActivityCount > 0 + || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")) guard shouldRun != isRunning else { if shouldRun { recoverIfNeeded() @@ -100,7 +116,23 @@ final class BackgroundAudioManager { let frameCount = AVAudioFrameCount(format.sampleRate) guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return } buffer.frameLength = frameCount - // PCM buffer is zero-initialized — pure silence + + // Fill with an inaudible but non-zero signal. Some iOS versions treat a + // purely digital-silent playback session as idle and reclaim it, which + // lets the app get suspended in the background. A tiny alternating sample + // (~-80 dBFS, DC-free) keeps the session genuinely "playing" while staying + // far below anything audible or disruptive to the foreground app's audio. + if !format.isInterleaved, let channels = buffer.floatChannelData { + let amplitude: Float = 0.0001 + let frames = Int(frameCount) + for channel in 0.. 0 + || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation")) + if shouldRun { + start() + } else if isRunning { stop() } } diff --git a/StikDebug/Services/DebugKeepAliveLease.swift b/StikDebug/Services/DebugKeepAliveLease.swift index 41659a5a..024859de 100644 --- a/StikDebug/Services/DebugKeepAliveLease.swift +++ b/StikDebug/Services/DebugKeepAliveLease.swift @@ -6,6 +6,11 @@ import Foundation import UIKit +/// Keeps StikDebug running in the background for the duration of a debug/JIT +/// session so the debug connection (and its heartbeat) survives being switched +/// out of the app — e.g. while playing a JIT-enabled game. If iOS suspends the +/// app, that connection goes idle and the device tears it down, which is what +/// makes the target app lose its session/JIT after a while. final class DebugKeepAliveLease { private let stateLock = NSLock() private var isActive = false @@ -25,13 +30,11 @@ final class DebugKeepAliveLease { stateLock.unlock() runOnMain { - BackgroundAudioManager.shared.requestStop() - BackgroundLocationManager.shared.requestStop() - - if self.backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(self.backgroundTaskID) - self.backgroundTaskID = .invalid - } + // Forced holds so the session stays alive even if the user turned the + // keep-alive toggles off; released here when the session ends. + BackgroundAudioManager.shared.requestStop(force: true) + BackgroundLocationManager.shared.requestStop(force: true) + self.endBackgroundTask() } } @@ -45,15 +48,42 @@ final class DebugKeepAliveLease { stateLock.unlock() runOnMain { - BackgroundAudioManager.shared.requestStart() - BackgroundLocationManager.shared.requestStart() - self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in - LogManager.shared.addWarningLog("Debug session background task expired") - self?.invalidate() + BackgroundAudioManager.shared.requestStart(force: true) + BackgroundLocationManager.shared.requestStart(force: true) + self.beginBackgroundTask() + } + } + + // MARK: - Background task (main-thread only) + + private func beginBackgroundTask() { + endBackgroundTask() + backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in + guard let self else { return } + // The silent-audio keep-alive is what actually sustains background + // execution, so do NOT tear the session down when this assertion + // expires (the old behaviour, which dropped long sessions). Release + // the expiring task and take a fresh one so the session keeps running. + self.stateLock.lock() + let stillActive = self.isActive + self.stateLock.unlock() + + if stillActive { + LogManager.shared.addInfoLog("Debug session background window renewed (keep-alive continues)") + self.beginBackgroundTask() + } else { + self.endBackgroundTask() } } } + private func endBackgroundTask() { + if backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } + } + private func runOnMain(_ work: @escaping () -> Void) { if Thread.isMainThread { work() diff --git a/StikDebug/Services/NetworkPathMonitor.swift b/StikDebug/Services/NetworkPathMonitor.swift new file mode 100644 index 00000000..a4b98962 --- /dev/null +++ b/StikDebug/Services/NetworkPathMonitor.swift @@ -0,0 +1,101 @@ +// +// NetworkPathMonitor.swift +// StikDebug +// + +import Foundation +import Network + +/// Observes the device's network path so the app never *assumes* Wi-Fi. +/// +/// StikDebug reaches the device's own services through LocalDevVPN's tunnel, +/// which works over Wi-Fi *or* cellular. This monitor reports what path is +/// actually available (including cellular and VPN) and broadcasts changes so the +/// tunnel can reconnect after a Wi-Fi↔cellular handoff instead of silently +/// staying dropped. +final class NetworkPathMonitor { + static let shared = NetworkPathMonitor() + + struct Status: CustomStringConvertible { + var isReachable: Bool + var isExpensive: Bool + var usesVPN: Bool + var interface: NWInterface.InterfaceType? + + var interfaceName: String { + switch interface { + case .wifi: return "Wi-Fi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .loopback: return "Loopback" + case .other: return "Other" + case .none: return "None" + @unknown default: return "Unknown" + } + } + + var description: String { + var parts = [isReachable ? "reachable" : "unreachable", interfaceName] + if usesVPN { parts.append("VPN") } + if isExpensive { parts.append("expensive") } + return parts.joined(separator: ", ") + } + } + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.stik.networkpath") + private let stateLock = NSLock() + private var started = false + private var current = Status(isReachable: false, isExpensive: false, usesVPN: false, interface: nil) + private var lastSignature: String? + + private init() {} + + var status: Status { + stateLock.lock() + defer { stateLock.unlock() } + return current + } + + func start() { + stateLock.lock() + guard !started else { + stateLock.unlock() + return + } + started = true + stateLock.unlock() + + monitor.pathUpdateHandler = { [weak self] path in + self?.handle(path) + } + monitor.start(queue: queue) + } + + private func handle(_ path: NWPath) { + // A utun (VPN) interface reports as `.other`; treat its presence as an + // active VPN. The primary usable interface is the first one actually in use. + let usesVPN = path.availableInterfaces.contains { $0.type == .other } + let interface = path.availableInterfaces.first(where: { path.usesInterfaceType($0.type) })?.type + ?? path.availableInterfaces.first?.type + + let status = Status( + isReachable: path.status == .satisfied, + isExpensive: path.isExpensive, + usesVPN: usesVPN, + interface: interface + ) + + let signature = "\(status.isReachable)|\(status.interfaceName)|\(status.usesVPN)" + + stateLock.lock() + current = status + let changed = signature != lastSignature + lastSignature = signature + stateLock.unlock() + + guard changed else { return } + LogManager.shared.addInfoLog("Network path: \(status.description)") + NotificationCenter.default.post(name: .networkPathDidChange, object: nil) + } +} diff --git a/StikDebug/Services/TunnelManager.swift b/StikDebug/Services/TunnelManager.swift index 2d0c83aa..7b31a3d9 100644 --- a/StikDebug/Services/TunnelManager.swift +++ b/StikDebug/Services/TunnelManager.swift @@ -11,8 +11,20 @@ final class TunnelManager: ObservableObject { @Published private(set) var isConnected = false private var isStarting = false - - private init() {} + private var pathChangeWorkItem: DispatchWorkItem? + + private init() { + // Reconnect when the network path changes (e.g. a Wi-Fi↔cellular handoff) + // so the tunnel comes back instead of staying dropped. Block-based + // observer avoids needing an @objc selector on this non-NSObject singleton. + NotificationCenter.default.addObserver( + forName: .networkPathDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.networkPathChanged() + } + } func markDisconnected() { runOnMain { @@ -41,13 +53,7 @@ final class TunnelManager: ObservableObject { isStarting = true DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in - let result: Result - do { - try JITEnableContext.shared.startTunnel() - result = .success(()) - } catch { - result = .failure(error as NSError) - } + let result = self.connectWithRetry() DispatchQueue.main.async { self.finishStart(result, showErrorUI: showErrorUI) @@ -55,6 +61,75 @@ final class TunnelManager: ObservableObject { } } + /// Attempts the tunnel connection a few times with a short backoff before + /// giving up. Cellular paths (and Wi-Fi↔cellular handoffs) are more prone to + /// transient connect failures than Wi-Fi, so a couple of retries turn a + /// spurious drop into a successful connection instead of an error alert. + private func connectWithRetry() -> Result { + let maxAttempts = 3 + var lastError: NSError? + + for attempt in 1...maxAttempts { + do { + try JITEnableContext.shared.startTunnel() + if attempt > 1 { + LogManager.shared.addInfoLog("Tunnel connected on attempt \(attempt)") + } + return .success(()) + } catch let error as NSError { + lastError = error + + if Self.isPermanentTunnelError(error) || attempt == maxAttempts { + break + } + + let backoff = TimeInterval(attempt) // 1s, then 2s + LogManager.shared.addWarningLog( + "Tunnel connect attempt \(attempt) failed: \(error.localizedDescription). Retrying in \(Int(backoff))s…" + ) + Thread.sleep(forTimeInterval: backoff) + } + } + + return .failure(lastError ?? NSError( + domain: "StikDebug", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Tunnel connection failed"] + )) + } + + /// True for failures that another attempt won't fix — they need user action + /// (fresh pairing file, valid target IP), not a retry. + private static func isPermanentTunnelError(_ error: NSError) -> Bool { + // -9 invalid/expired pairing, -17 missing pairing, -18 bad target IP. + if [-9, -17, -18].contains(error.code) { + return true + } + let message = error.localizedDescription.lowercased() + return message.contains("parse target ip") + || message.contains("pairing file not found") + } + + private func networkPathChanged() { + // Debounce: a handoff can emit several path updates in quick succession. + pathChangeWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + self?.handleNetworkPathChange() + } + pathChangeWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: item) + } + + private func handleNetworkPathChange() { + let status = NetworkPathMonitor.shared.status + guard status.isReachable else { return } + + // Re-establish the primary tunnel if it dropped during the change. + guard !isConnected else { return } + LogManager.shared.addInfoLog("Network path changed (\(status.description)); reconnecting tunnel") + start(showErrorUI: false) + } + private func finishStart(_ result: Result, showErrorUI: Bool) { isStarting = false @@ -159,7 +234,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String { recoverySteps = [ "Open LocalDevVPN and confirm the VPN is connected.", "Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.", - "Reconnect Wi-Fi and LocalDevVPN, then try again.", + "Reconnect LocalDevVPN, then try again (Wi-Fi or cellular both work).", "If this keeps happening, select a fresh pairing file." ] } else if error.code == -18 || lowercasedMessage.contains("parse target ip") { @@ -171,7 +246,7 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String { } else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") { likelyCause = "The app could not reach the device before the connection timed out." recoverySteps = [ - "Confirm Wi-Fi and LocalDevVPN are both connected.", + "Confirm you have network access (Wi-Fi or cellular) and LocalDevVPN is connected.", "Wake and unlock the target device.", "Confirm LocalDevVPN is exposing the device at \(targetIP)." ] @@ -180,12 +255,12 @@ private func tunnelConnectionAlertMessage(for error: NSError) -> String { recoverySteps = [ "Disconnect and reconnect LocalDevVPN.", "Confirm iOS shows the VPN indicator.", - "Try switching Wi-Fi off and on." + "Toggle your network connection (or Airplane Mode) off and on." ] } else { likelyCause = "The tunnel could not be created." recoverySteps = [ - "Confirm Wi-Fi and LocalDevVPN are connected.", + "Confirm network access (Wi-Fi or cellular) and LocalDevVPN are connected.", "Wake and unlock the target device.", "Reconnect LocalDevVPN, then try again." ] diff --git a/StikDebug/Support/NotificationName+StikDebug.swift b/StikDebug/Support/NotificationName+StikDebug.swift index 7cb27e8f..5340673a 100644 --- a/StikDebug/Support/NotificationName+StikDebug.swift +++ b/StikDebug/Support/NotificationName+StikDebug.swift @@ -8,4 +8,7 @@ import Foundation extension Notification.Name { static let pairingFileImported = Notification.Name("PairingFileImported") static let intentJSScriptReady = Notification.Name("intentJSScriptReady") + /// Posted when the reachable network path changes (interface type, VPN + /// presence, or reachability), so the tunnel can revalidate/reconnect. + static let networkPathDidChange = Notification.Name("NetworkPathDidChange") } diff --git a/StikDebug/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 0e609a3e..0a8fd93c 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -72,6 +72,28 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } + /// The JIT script to run while holding an app alive in the background, or + /// `nil` to hold the app with a plain debugger attach. + /// + /// Only apps with a user-assigned or name-matched (known JIT app) script get + /// a script. Those apps request executable memory via `brk #0xf00d` syscalls + /// that the script must service, so without it they hang. Apps that do *not* + /// use JIT — e.g. Roblox and most games — are deliberately held with a plain + /// attach instead: running the JIT breakpoint handler against them makes it + /// chase every signal the app raises across all of its threads, which floods + /// the log (and can crash a large multi-threaded app or StikDebug itself). A + /// plain attach keeps them alive in the background without interfering. + /// + /// 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 + } + + return preferredScript(for: bundleID, fileManager: fileManager) + } + static func favoriteAppName( for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index a6478532..35d3fefb 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -11,8 +11,10 @@ struct HomeView: View { @AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false @AppStorage("bundleID") private var bundleID: String = "" @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true + @AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false @ObservedObject private var mounting = MountingProgress.shared + @ObservedObject private var aliveManager = BackgroundAliveManager.shared @State private var hasAppeared = false @State private var pendingJITEnableConfiguration: JITEnableConfiguration? @@ -34,8 +36,26 @@ struct HomeView: View { InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle Haptics.medium() - startJITInBackground(bundleID: selectedBundle, displayName: selectedName) - }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + if keepAppAliveBackground { + startKeepAlive(bundleID: selectedBundle, displayName: selectedName) + } else { + startJITInBackground(bundleID: selectedBundle, displayName: selectedName) + } + }, 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) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .overlay(alignment: .bottom) { if let debugFeedback { debugFeedbackView(debugFeedback) @@ -43,6 +63,7 @@ struct HomeView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .animation(.default, value: aliveManager.activeAppName) .onAppear(perform: handleAppear) .onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification) .onReceive(timer) { _ in @@ -238,6 +259,63 @@ struct HomeView: View { } } + private func keepAliveBanner(appName: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "bolt.circle.fill") + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 1) { + Text(String(format: "Keeping %@ alive".localized, appName)) + .font(.subheadline.weight(.semibold)) + Text("Held in the background".localized) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Button("Stop".localized) { + Haptics.medium() + BackgroundAliveManager.shared.stop() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(.red) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Capsule().fill(.ultraThinMaterial)) + .shadow(radius: 4) + .padding(.horizontal, 20) + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: "Keeping %@ alive in the background".localized, appName)) + } + + private func startKeepAlive(bundleID: String, displayName: String?) { + let name = displayName ?? bundleID + guard !aliveManager.isActive else { + showAlert( + title: "Keep-Alive Active".localized, + message: "StikDebug is already holding an app alive in the background. Stop it before starting another.".localized, + showOk: true + ) + return + } + // 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 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, token: token) + } + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { @@ -259,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 69437a6e..d9d17e2d 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/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4d..4364f831 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -42,16 +42,173 @@ private struct RouteSimulationPlan { private enum RouteSimulationDefaults { static let pathSamplingDistance: CLLocationDistance = 10 + /// Cap on densified route points. Playback interpolates within segments per + /// tick regardless, so coarser spacing on long routes costs no smoothness — + /// it just keeps memory, polyline rendering, and sample building bounded. + static let maxDisplayCoordinateCount = 25_000 static let playbackTickInterval: TimeInterval = 0.5 static let minimumSpeedMetersPerSecond: CLLocationSpeed = 1.0 static let importedRouteFallbackSpeedMetersPerSecond: CLLocationSpeed = 13.4 } +/// 10 m spacing for short routes, widening once a route would exceed the +/// display-point cap (e.g. a 500 km route samples every ~20 m instead). +private func adaptiveSamplingDistance(for coordinates: [CLLocationCoordinate2D]) -> CLLocationDistance { + let totalDistance = distanceAlong(coordinates) + return max( + RouteSimulationDefaults.pathSamplingDistance, + totalDistance / Double(RouteSimulationDefaults.maxDisplayCoordinateCount) + ) +} + private struct RoutePlaybackSample { let coordinate: CLLocationCoordinate2D let delayFromPrevious: TimeInterval } +enum SpeedProfile: String, CaseIterable, Identifiable { + case walking + case jogging + case cycling + case driving + case bus + case custom + + var id: String { rawValue } + + var title: String { + switch self { + case .walking: return "Walking" + case .jogging: return "Jogging" + case .cycling: return "Cycling" + case .driving: return "Driving" + case .bus: return "Bus" + case .custom: return "Custom" + } + } + + var systemImage: String { + switch self { + case .walking: return "figure.walk" + case .jogging: return "figure.run" + case .cycling: return "bicycle" + case .driving: return "car.fill" + case .bus: return "bus.fill" + case .custom: return "speedometer" + } + } + + /// Fixed pace in m/s, or nil when speed comes from road data / user input. + var fixedSpeedMetersPerSecond: CLLocationSpeed? { + switch self { + case .walking: return 1.4 + case .jogging: return 2.7 + case .cycling: return 5.5 + case .driving, .bus, .custom: return nil + } + } + + /// Walking-ish profiles should route along footpaths, not roads. + var prefersWalkingDirections: Bool { + switch self { + case .walking, .jogging: return true + default: return false + } + } +} + +enum MapDisplayMode: String, CaseIterable, Identifiable { + case standard + case satellite + case hybrid + + var id: String { rawValue } + + var title: String { + switch self { + case .standard: return "Standard" + case .satellite: return "Satellite" + case .hybrid: return "Hybrid" + } + } + + var systemImage: String { + switch self { + case .standard: return "map" + case .satellite: return "globe.americas.fill" + case .hybrid: return "square.2.layers.3d" + } + } + + /// Pure satellite imagery can't render traffic or labeled points of interest. + var supportsOverlays: Bool { + self != .satellite + } +} + +enum MapPointsOfInterestMode: String, CaseIterable, Identifiable { + case all + case transit + case hidden + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All Places" + case .transit: return "Transit Stops" + case .hidden: return "Hidden" + } + } + + var systemImage: String { + switch self { + case .all: return "mappin.and.ellipse" + case .transit: return "bus.fill" + case .hidden: return "eye.slash" + } + } + + var categories: PointOfInterestCategories { + switch self { + case .all: return .all + case .transit: return .including([.publicTransport]) + case .hidden: return .excludingAll + } + } +} + +private struct SpeedProfileSettings { + let profile: SpeedProfile + let customSpeedMetersPerSecond: CLLocationSpeed + + static let busSpeedCapMetersPerSecond: CLLocationSpeed = 13.9 // ~50 km/h + static let busStopApproachRadius: CLLocationDistance = 60 // begin slowing + static let busStopApproachSpeed: CLLocationSpeed = 4.0 // crawl near stops + static let busStopDwellSeconds: TimeInterval = 12 // doors open + static let busStopSnapRadius: CLLocationDistance = 25 // dwell trigger + + /// Resolve the playback speed for one segment given the road limit (if any). + func speed(forRoadLimit roadLimit: CLLocationSpeed?, fallback: CLLocationSpeed) -> CLLocationSpeed { + if let fixed = profile.fixedSpeedMetersPerSecond { + return fixed + } + switch profile { + case .custom: + return max(customSpeedMetersPerSecond, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + case .bus: + return min(roadLimit ?? fallback, Self.busSpeedCapMetersPerSecond) + default: // .driving — original behavior + return roadLimit ?? fallback + } + } +} + +private struct RouteSpeedContext { + let ways: [OpenStreetMapWay] + let busStops: [CLLocationCoordinate2D] +} + private struct OpenStreetMapWay { let geometry: [CLLocationCoordinate2D] let speedLimitMetersPerSecond: CLLocationSpeed @@ -60,16 +217,75 @@ private struct OpenStreetMapWay { private enum OpenStreetMapSpeedLimitService { static let endpoint = URL(string: "https://overpass-api.de/api/interpreter")! static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! - static let boundingBoxPaddingDegrees = 0.0015 static let nearestWayThreshold: CLLocationDistance = 40 + + // Route chunking. The whole route is split into ~5 km chunks fetched as + // independent requests, each queried by its own small bounding box rather + // than an `around(radius, points…)` list. `around` costs scale with + // points-in-list × candidate-elements-in-area and reliably blew past the + // server timeout on anything but the shortest stretches — measured + // 16-23s (or outright timeout) for a dense-area chunk that a bbox query + // resolved in 3-8s for the same area. A bbox test is an O(1) rectangle + // check per candidate regardless of the route's shape, so cost no longer + // scales with corridor point density — the actual cause of stops loading + // inconsistently (denser areas or longer stretches pushed the old query + // past its timeout more often, but never predictably). + static let chunkTargetDistance: CLLocationDistance = 5_000 // ~5 km of route per request + static let maxChunkCount = 80 // caps requests on very long routes + static let maxConcurrentChunkRequests = 3 + // Padding around each chunk's tight point bbox. Comfortably larger than + // the client-side match thresholds below (40 m ways / 90 m stops) so nothing + // near the edge of a chunk is missed by the coarse server-side box. + static let chunkBBoxPadding: CLLocationDistance = 120 + static let chunkServerTimeout: TimeInterval = 20 // Overpass-side [timeout:] + static let requestTimeout: TimeInterval = 30 // client-side, per chunk; + // must exceed the server timeout with + // margin — Overpass still takes several + // seconds to flush its own timeout reply + static let chunkRetryAttempts = 2 // total tries per chunk before giving up + + // Physical stops are often mapped twice (platform + stop_position a few + // meters apart); merge anything closer than this so markers and dwells + // aren't doubled. Opposite-direction stop pairs sit 20 m+ apart and survive. + static let busStopDedupeRadius: CLLocationDistance = 15 + + // Spatial index cell size (~440 m at the equator). Comfortably larger than + // every search radius above, so a ±1 cell ring around a query point always + // covers the relevant neighborhood. + static let indexCellSizeDegrees = 0.004 +} + +/// Integer cell coordinate used by the spatial indexes below. +private struct SpatialCellKey: Hashable { + let x: Int + let y: Int + + init(x: Int, y: Int) { + self.x = x + self.y = y + } + + init(latitude: Double, longitude: Double, cellSizeDegrees: Double) { + x = Int(floor(longitude / cellSizeDegrees)) + y = Int(floor(latitude / cellSizeDegrees)) + } } private struct OverpassResponse: Decodable { let elements: [Element] + // Present only on a server-side problem (e.g. "runtime error: Query timed + // out…"), never on a clean result. Overpass replies HTTP 200 with an empty + // `elements` array in this case rather than an error status, so without + // checking this field a timed-out chunk looked identical to "no data near + // this stretch of route" — the actual cause of stops loading inconsistently. + let remark: String? struct Element: Decodable { + let id: Int? let tags: [String: String]? let geometry: [Coordinate]? + let lat: Double? + let lon: Double? } struct Coordinate: Decodable { @@ -205,7 +421,52 @@ private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocatio return directionalValues.min() } -private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? { +/// Split the route into chunks of about `chunkTargetDistance` real-world +/// meters each (walked along the polyline, not raw point count), capped at +/// `maxChunkCount` chunks so an extremely long route still bounds the number +/// of requests made. Consecutive chunks share one boundary point so nothing +/// near a seam falls outside every chunk's bounding box. +private func routeChunks(from coordinates: [CLLocationCoordinate2D]) -> [[CLLocationCoordinate2D]] { + guard coordinates.count > 1 else { return [] } + + let totalDistance = distanceAlong(coordinates) + let chunkDistance = max( + OpenStreetMapSpeedLimitService.chunkTargetDistance, + totalDistance / Double(OpenStreetMapSpeedLimitService.maxChunkCount) + ) + + var chunks: [[CLLocationCoordinate2D]] = [] + var current: [CLLocationCoordinate2D] = [coordinates[0]] + var accumulated: CLLocationDistance = 0 + + for (previous, point) in zip(coordinates, coordinates.dropFirst()) { + accumulated += CLLocation(latitude: previous.latitude, longitude: previous.longitude) + .distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude)) + current.append(point) + if accumulated >= chunkDistance { + chunks.append(current) + current = [point] // shared boundary point, so no coverage gap at the seam + accumulated = 0 + } + } + if current.count > 1 { + chunks.append(current) + } + return chunks +} + +private struct ChunkBoundingBox { + let south: Double + let west: Double + let north: Double + let east: Double +} + +/// Tight bounding box of `coordinates`, padded by `paddingMeters`. Since a +/// straight segment between two consecutive route points lies within their +/// combined bbox, padding the point-only bbox by at least the largest +/// client-side match radius guarantees nothing near the true path is missed. +private func boundingBox(for coordinates: [CLLocationCoordinate2D], paddingMeters: CLLocationDistance) -> ChunkBoundingBox? { guard let first = coordinates.first else { return nil } var minLatitude = first.latitude @@ -220,33 +481,89 @@ private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? maxLongitude = max(maxLongitude, coordinate.longitude) } - let padding = OpenStreetMapSpeedLimitService.boundingBoxPaddingDegrees - let south = minLatitude - padding - let west = minLongitude - padding - let north = maxLatitude + padding - let east = maxLongitude + padding + let midLatitudeRadians = (minLatitude + maxLatitude) / 2 * .pi / 180 + let latitudePadding = paddingMeters / 111_320 + let longitudePadding = paddingMeters / (111_320 * max(cos(midLatitudeRadians), 0.01)) + + return ChunkBoundingBox( + south: minLatitude - latitudePadding, + west: minLongitude - longitudePadding, + north: maxLatitude + latitudePadding, + east: maxLongitude + longitudePadding + ) +} + +private func overpassChunkQuery(forBoundingBox bbox: ChunkBoundingBox, includeBusStops: Bool) -> String { + let bboxString = String(format: "%.6f,%.6f,%.6f,%.6f", bbox.south, bbox.west, bbox.north, bbox.east) + + // Bus stops are mapped inconsistently in OSM: the legacy `highway=bus_stop` + // tag, and the newer public_transport schema (platform / stop_position with + // `bus=yes`). One regex clause pulls that superset in a single pass — + // `tagsDescribeBusStop` keeps only real bus stops client-side. + var busStopClause = "" + if includeBusStops { + busStopClause = """ - let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) + node(\(bboxString))[~"^(highway|public_transport)$"~"^(bus_stop|platform|stop_position)$"]; + """ + } + // A bounding-box filter is an O(1) rectangle test per candidate element, + // independent of the chunk's shape or point density — unlike `around` + // with a point list, whose cost scales with points × candidates and + // reliably exceeded the server timeout on anything but the shortest + // stretches. Extra candidates a loose box admits are rejected client-side + // by `nearestWayThreshold` / `hasBusStop(within:)`, so precision is + // unaffected. return """ - [out:json][timeout:20]; + [out:json][timeout:\(Int(OpenStreetMapSpeedLimitService.chunkServerTimeout))]; + way(\(bboxString))[highway]->.roads; ( - way(\(bbox))[highway][maxspeed]; - way(\(bbox))[highway]["maxspeed:forward"]; - way(\(bbox))[highway]["maxspeed:backward"]; + way.roads[maxspeed]; + way.roads["maxspeed:forward"]; + way.roads["maxspeed:backward"];\(busStopClause) ); out tags geom; """ } -private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) async throws -> [OpenStreetMapWay] { - guard let query = overpassQuery(for: coordinates) else { return [] } +private func tagsDescribeBusStop(_ tags: [String: String]) -> Bool { + if tags["highway"] == "bus_stop" { + return true + } + let publicTransport = tags["public_transport"] + if publicTransport == "platform" || publicTransport == "stop_position" { + return tags["bus"] == "yes" + } + return false +} - var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "data", value: query)] - guard let url = components?.url else { return [] } +private struct OverpassChunkResult { + let ways: [(id: Int, value: OpenStreetMapWay)] + let busStops: [(id: Int, value: CLLocationCoordinate2D)] +} - let (data, response) = try await URLSession.shared.data(from: url) +private func fetchSpeedContextChunk( + bbox: ChunkBoundingBox, + includeBusStops: Bool +) async throws -> OverpassChunkResult { + let query = overpassChunkQuery(forBoundingBox: bbox, includeBusStops: includeBusStops) + + // POST the query: this stays small since the request body is now just a + // bbox rather than a whole corridor of points. The explicit timeout keeps + // a slow Overpass server from stalling this chunk — a failed chunk only + // costs its own stretch of road data. + var request = URLRequest(url: OpenStreetMapSpeedLimitService.endpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = OpenStreetMapSpeedLimitService.requestTimeout + + var formAllowed = CharacterSet.alphanumerics + formAllowed.insert(charactersIn: "-._~") + let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: formAllowed) ?? "" + request.httpBody = "data=\(encodedQuery)".data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { @@ -258,58 +575,331 @@ private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) a } let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) - return decoded.elements.compactMap { element in - guard let tags = element.tags, + + // Overpass replies HTTP 200 with an empty `elements` array and a `remark` + // when it hits its own timeout or otherwise can't complete the query — + // treat that the same as a network failure (retryable) rather than + // silently accepting "no data found here". + if let remark = decoded.remark { + throw NSError( + domain: "OpenStreetMapSpeedLimits", + code: -2, + userInfo: [NSLocalizedDescriptionKey: "Overpass reported an error for this chunk: \(remark)"] + ) + } + + let ways: [(id: Int, value: OpenStreetMapWay)] = decoded.elements.compactMap { element in + guard let id = element.id, + let tags = element.tags, let speedLimit = speedLimitMetersPerSecond(from: tags), let geometry = element.geometry?.map({ CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }), geometry.count > 1 else { return nil } - return OpenStreetMapWay( + return (id, OpenStreetMapWay( geometry: geometry, speedLimitMetersPerSecond: speedLimit + )) + } + + let busStops: [(id: Int, value: CLLocationCoordinate2D)] = decoded.elements.compactMap { element in + guard let id = element.id, + let lat = element.lat, let lon = element.lon, + let tags = element.tags, + tagsDescribeBusStop(tags) else { + return nil + } + return (id, CLLocationCoordinate2D(latitude: lat, longitude: lon)) + } + + return OverpassChunkResult(ways: ways, busStops: busStops) +} + +/// Retries a chunk fetch on failure (network error, HTTP error, or a +/// server-side timeout surfaced via `remark`) with a short backoff — the +/// public Overpass instance is a shared, load-dependent resource, so a single +/// slow response shouldn't cost that stretch of the route its data. Returns +/// `nil` only once every attempt has failed. +private func fetchSpeedContextChunkWithRetry( + bbox: ChunkBoundingBox, + includeBusStops: Bool +) async -> OverpassChunkResult? { + let maxAttempts = OpenStreetMapSpeedLimitService.chunkRetryAttempts + for attempt in 1...maxAttempts { + do { + return try await fetchSpeedContextChunk(bbox: bbox, includeBusStops: includeBusStops) + } catch { + guard attempt < maxAttempts else { return nil } + try? await Task.sleep(for: .seconds(Double(attempt) * 1.5)) + } + } + return nil +} + +/// Merge stops closer together than `radius` — a stop's platform and +/// stop_position nodes are usually a few meters apart and would otherwise +/// double every marker and dwell. Grid-bucketed so it stays linear. +private func dedupedBusStops( + _ stops: [CLLocationCoordinate2D], + radius: CLLocationDistance +) -> [CLLocationCoordinate2D] { + guard stops.count > 1 else { return stops } + + let cellSize = OpenStreetMapSpeedLimitService.indexCellSizeDegrees + var kept: [CLLocationCoordinate2D] = [] + var grid: [SpatialCellKey: [CLLocationCoordinate2D]] = [:] + + for stop in stops { + let location = CLLocation(latitude: stop.latitude, longitude: stop.longitude) + let center = SpatialCellKey( + latitude: stop.latitude, + longitude: stop.longitude, + cellSizeDegrees: cellSize ) + + var isDuplicate = false + search: for dx in -1...1 { + for dy in -1...1 { + guard let bucket = grid[SpatialCellKey(x: center.x + dx, y: center.y + dy)] else { + continue + } + for existing in bucket { + let existingLocation = CLLocation(latitude: existing.latitude, longitude: existing.longitude) + if location.distance(from: existingLocation) < radius { + isDuplicate = true + break search + } + } + } + } + + if !isDuplicate { + kept.append(stop) + grid[center, default: []].append(stop) + } } + + return kept } -private func nearestSpeedLimit( - forSegmentFrom start: CLLocationCoordinate2D, - to end: CLLocationCoordinate2D, - using ways: [OpenStreetMapWay] -) -> CLLocationSpeed? { - let midpoint = MKMapPoint(midpointCoordinate(from: start, to: end)) - var bestMatch: (speed: CLLocationSpeed, distance: CLLocationDistance)? - - for way in ways { - for (wayStart, wayEnd) in zip(way.geometry, way.geometry.dropFirst()) { - let candidateDistance = distanceFromPoint( - midpoint, - toSegmentFrom: MKMapPoint(wayStart), - to: MKMapPoint(wayEnd) - ) +/// Fetches road speed limits (and optionally bus stops) along the route. +/// +/// The route is split into distance-based chunks, each queried by its own +/// bounding box, at most `maxConcurrentChunkRequests` in flight, deduplicated +/// by OSM element id (chunks overlap at their seams). A chunk that still +/// fails after retrying is tolerated — whatever data arrived from the other +/// chunks is still used. When bus stops are requested, `onPartialBusStops` +/// fires with the cumulative deduplicated stops as each chunk lands, so +/// markers appear progressively instead of after one all-or-nothing query. +private func fetchRouteSpeedContext( + for coordinates: [CLLocationCoordinate2D], + includeBusStops: Bool, + onPartialBusStops: (@Sendable ([CLLocationCoordinate2D]) -> Void)? = nil +) async -> RouteSpeedContext { + let bboxes = routeChunks(from: coordinates).compactMap { + boundingBox(for: $0, paddingMeters: OpenStreetMapSpeedLimitService.chunkBBoxPadding) + } + guard !bboxes.isEmpty else { return RouteSpeedContext(ways: [], busStops: []) } + + var ways: [OpenStreetMapWay] = [] + var busStops: [CLLocationCoordinate2D] = [] + var seenWayIDs: Set = [] + var seenStopIDs: Set = [] + var failedChunkCount = 0 + + await withTaskGroup(of: OverpassChunkResult?.self) { group in + var pending = bboxes.makeIterator() + var inFlight = 0 + while inFlight < OpenStreetMapSpeedLimitService.maxConcurrentChunkRequests, + let bbox = pending.next() { + group.addTask { await fetchSpeedContextChunkWithRetry(bbox: bbox, includeBusStops: includeBusStops) } + inFlight += 1 + } + + for await result in group { + if Task.isCancelled { + group.cancelAll() + break + } + if let bbox = pending.next() { + group.addTask { await fetchSpeedContextChunkWithRetry(bbox: bbox, includeBusStops: includeBusStops) } + } + guard let result else { + failedChunkCount += 1 + continue + } + + for way in result.ways where seenWayIDs.insert(way.id).inserted { + ways.append(way.value) + } + + var addedStops = false + for stop in result.busStops where seenStopIDs.insert(stop.id).inserted { + busStops.append(stop.value) + addedStops = true + } - if bestMatch == nil || candidateDistance < bestMatch!.distance { - bestMatch = (way.speedLimitMetersPerSecond, candidateDistance) + if includeBusStops, addedStops { + onPartialBusStops?(dedupedBusStops( + busStops, + radius: OpenStreetMapSpeedLimitService.busStopDedupeRadius + )) } } } - guard let bestMatch, - bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { - return nil + if failedChunkCount > 0 { + LogManager.shared.addWarningLog( + "Route data: \(failedChunkCount) of \(bboxes.count) map-data chunks failed after retrying; continuing with partial coverage" + ) + } + + return RouteSpeedContext( + ways: ways, + busStops: dedupedBusStops( + busStops, + radius: OpenStreetMapSpeedLimitService.busStopDedupeRadius + ) + ) +} + +private struct IndexedWaySegment { + let start: MKMapPoint + let end: MKMapPoint + let speedLimitMetersPerSecond: CLLocationSpeed +} + +/// Grid-bucketed index over the Overpass response. The old implementation +/// rescanned every way segment for every route segment (O(route × ways)), +/// which made long routes take minutes of CPU; bucketing by ~440 m cells and +/// probing only the 3×3 neighborhood turns each lookup into a handful of +/// segment checks. All search radii (≤ 90 m) are far smaller than a cell at +/// non-polar latitudes, so ring probing never misses a legitimate match. +private struct RouteSpeedIndex { + private var waySegments: [SpatialCellKey: [IndexedWaySegment]] = [:] + private var busStopCells: [SpatialCellKey: [CLLocationCoordinate2D]] = [:] + private let cellSize = OpenStreetMapSpeedLimitService.indexCellSizeDegrees + + init(context: RouteSpeedContext) { + for way in context.ways { + for (wayStart, wayEnd) in zip(way.geometry, way.geometry.dropFirst()) { + let segment = IndexedWaySegment( + start: MKMapPoint(wayStart), + end: MKMapPoint(wayEnd), + speedLimitMetersPerSecond: way.speedLimitMetersPerSecond + ) + // A segment can cross cell boundaries; register it in every + // cell its bounding box touches so ring probes always see it. + let minX = Int(floor(min(wayStart.longitude, wayEnd.longitude) / cellSize)) + let maxX = Int(floor(max(wayStart.longitude, wayEnd.longitude) / cellSize)) + let minY = Int(floor(min(wayStart.latitude, wayEnd.latitude) / cellSize)) + let maxY = Int(floor(max(wayStart.latitude, wayEnd.latitude) / cellSize)) + // Most road segments span a cell or two, but sparsely-noded + // rural highways can legitimately run tens of km between OSM + // geometry points, so allow a generous span (~57 km at the + // equator). Anything larger is broken data — e.g. an + // antimeridian jump spans ~90,000 cells — and is skipped rather + // than flooding the index. The earlier ≤8-cell cap (~3.5 km) + // silently dropped those long segments, so those stretches lost + // their real speed limit and fell back to average pacing. + let maxCellSpan = 128 + guard maxX - minX <= maxCellSpan, maxY - minY <= maxCellSpan else { continue } + for x in minX...maxX { + for y in minY...maxY { + waySegments[SpatialCellKey(x: x, y: y), default: []].append(segment) + } + } + } + } + + for stop in context.busStops { + let key = SpatialCellKey( + latitude: stop.latitude, + longitude: stop.longitude, + cellSizeDegrees: cellSize + ) + busStopCells[key, default: []].append(stop) + } + } + + func nearestSpeedLimit( + forSegmentFrom start: CLLocationCoordinate2D, + to end: CLLocationCoordinate2D + ) -> CLLocationSpeed? { + guard !waySegments.isEmpty else { return nil } + + let midpoint = midpointCoordinate(from: start, to: end) + let midMapPoint = MKMapPoint(midpoint) + let center = SpatialCellKey( + latitude: midpoint.latitude, + longitude: midpoint.longitude, + cellSizeDegrees: cellSize + ) + + var bestMatch: (speed: CLLocationSpeed, distance: CLLocationDistance)? + for dx in -1...1 { + for dy in -1...1 { + guard let bucket = waySegments[SpatialCellKey(x: center.x + dx, y: center.y + dy)] else { + continue + } + for segment in bucket { + let candidateDistance = distanceFromPoint( + midMapPoint, + toSegmentFrom: segment.start, + to: segment.end + ) + if bestMatch == nil || candidateDistance < bestMatch!.distance { + bestMatch = (segment.speedLimitMetersPerSecond, candidateDistance) + } + } + } + } + + guard let bestMatch, + bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { + return nil + } + + return bestMatch.speed } - return bestMatch.speed + func hasBusStop(within radius: CLLocationDistance, of coordinate: CLLocationCoordinate2D) -> Bool { + guard !busStopCells.isEmpty else { return false } + + let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let center = SpatialCellKey( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + cellSizeDegrees: cellSize + ) + + for dx in -1...1 { + for dy in -1...1 { + guard let bucket = busStopCells[SpatialCellKey(x: center.x + dx, y: center.y + dy)] else { + continue + } + for stop in bucket { + let stopLocation = CLLocation(latitude: stop.latitude, longitude: stop.longitude) + if location.distance(from: stopLocation) <= radius { + return true + } + } + } + } + return false + } } private func buildPlaybackSamples( from displayCoordinates: [CLLocationCoordinate2D], - speedWays: [OpenStreetMapWay], - fallbackSpeedMetersPerSecond: CLLocationSpeed + speedContext: RouteSpeedContext, + fallbackSpeedMetersPerSecond: CLLocationSpeed, + speedSettings: SpeedProfileSettings ) -> [RoutePlaybackSample] { guard let firstCoordinate = displayCoordinates.first else { return [] } + let speedIndex = RouteSpeedIndex(context: speedContext) var samples = [RoutePlaybackSample(coordinate: firstCoordinate, delayFromPrevious: 0)] for (start, end) in zip(displayCoordinates, displayCoordinates.dropFirst()) { @@ -317,8 +907,22 @@ private func buildPlaybackSamples( .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) guard segmentDistance > 0 else { continue } - let speedLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedWays) ?? fallbackSpeedMetersPerSecond - let clampedSpeed = max(speedLimit, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + let roadLimit = speedIndex.nearestSpeedLimit(forSegmentFrom: start, to: end) + var speed = speedSettings.speed(forRoadLimit: roadLimit, fallback: fallbackSpeedMetersPerSecond) + + // Bus mode: ease off when approaching a stop. + if speedSettings.profile == .bus, + !speedContext.busStops.isEmpty { + let midpoint = midpointCoordinate(from: start, to: end) + if speedIndex.hasBusStop( + within: SpeedProfileSettings.busStopApproachRadius, + of: midpoint + ) { + speed = min(speed, SpeedProfileSettings.busStopApproachSpeed) + } + } + + let clampedSpeed = max(speed, RouteSimulationDefaults.minimumSpeedMetersPerSecond) let segmentTravelTime = segmentDistance / clampedSpeed let segmentStepCount = max(1, Int(ceil(segmentTravelTime / RouteSimulationDefaults.playbackTickInterval))) let stepDelay = segmentTravelTime / Double(segmentStepCount) @@ -335,19 +939,101 @@ private func buildPlaybackSamples( } } + // Bus mode: dwell once at each stop along the route (doors open, people shuffle). + if speedSettings.profile == .bus, !speedContext.busStops.isEmpty, samples.count > 1 { + // Bucket the samples by grid cell so each stop only compares against + // nearby samples instead of the entire (potentially huge) sample list. + let cellSize = OpenStreetMapSpeedLimitService.indexCellSizeDegrees + var sampleCells: [SpatialCellKey: [Int]] = [:] + for (index, sample) in samples.enumerated() { + let key = SpatialCellKey( + latitude: sample.coordinate.latitude, + longitude: sample.coordinate.longitude, + cellSizeDegrees: cellSize + ) + sampleCells[key, default: []].append(index) + } + + var dwellIndices: Set = [] + for stop in speedContext.busStops { + let stopLocation = CLLocation(latitude: stop.latitude, longitude: stop.longitude) + let center = SpatialCellKey( + latitude: stop.latitude, + longitude: stop.longitude, + cellSizeDegrees: cellSize + ) + var bestIndex: Int? + var bestDistance = SpeedProfileSettings.busStopSnapRadius + for dx in -1...1 { + for dy in -1...1 { + guard let bucket = sampleCells[SpatialCellKey(x: center.x + dx, y: center.y + dy)] else { + continue + } + for index in bucket { + let sample = samples[index] + let distance = stopLocation.distance( + from: CLLocation( + latitude: sample.coordinate.latitude, + longitude: sample.coordinate.longitude + ) + ) + if distance <= bestDistance { + bestDistance = distance + bestIndex = index + } + } + } + } + if let bestIndex, bestIndex > 0 { + dwellIndices.insert(bestIndex) + } + } + + if !dwellIndices.isEmpty { + samples = samples.enumerated().map { index, sample in + guard dwellIndices.contains(index) else { return sample } + return RoutePlaybackSample( + coordinate: sample.coordinate, + delayFromPrevious: sample.delayFromPrevious + SpeedProfileSettings.busStopDwellSeconds + ) + } + } + } + return samples } +private struct RoutePlaybackPrefetchResult { + let samples: [RoutePlaybackSample] + let busStops: [CLLocationCoordinate2D] +} + private func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], - fallbackSpeedMetersPerSecond: CLLocationSpeed -) async -> [RoutePlaybackSample] { - let speedWays = (try? await fetchOpenStreetMapWays(for: displayCoordinates)) ?? [] - return buildPlaybackSamples( + fallbackSpeedMetersPerSecond: CLLocationSpeed, + speedSettings: SpeedProfileSettings, + onPartialBusStops: (@Sendable ([CLLocationCoordinate2D]) -> Void)? = nil +) async -> RoutePlaybackPrefetchResult { + let needsRoadData = speedSettings.profile.fixedSpeedMetersPerSecond == nil + && speedSettings.profile != .custom + let context: RouteSpeedContext + if needsRoadData { + context = await fetchRouteSpeedContext( + for: displayCoordinates, + includeBusStops: speedSettings.profile == .bus, + onPartialBusStops: onPartialBusStops + ) + } else { + // Fixed/custom pace: no need to bother Overpass at all. + context = RouteSpeedContext(ways: [], busStops: []) + } + let samples = buildPlaybackSamples( from: displayCoordinates, - speedWays: speedWays, - fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond + speedContext: context, + fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond, + speedSettings: speedSettings ) + return RoutePlaybackPrefetchResult(samples: samples, busStops: context.busStops) } private enum CoordinateImportError: LocalizedError { @@ -749,6 +1435,8 @@ struct LocationSimulationView: View { @State private var routePlan: RouteSimulationPlan? @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() @@ -767,6 +1455,96 @@ struct LocationSimulationView: View { @State private var showSaveBookmark = false @State private var newBookmarkName = "" + // Map appearance + @AppStorage("mapDisplayMode") private var mapDisplayModeRawValue: String = MapDisplayMode.standard.rawValue + @AppStorage("mapShowsTraffic") private var mapShowsTraffic: Bool = false + @AppStorage("mapPointsOfInterestMode") private var mapPointsOfInterestRawValue: String = MapPointsOfInterestMode.all.rawValue + + // Speed profile + @AppStorage("routeSpeedProfile") private var speedProfileRawValue: String = SpeedProfile.driving.rawValue + @AppStorage("routeSpeedCustomKmh") private var customSpeedKmh: Double = 30 + @State private var showCustomSpeedPrompt = false + @State private var customSpeedInput = "" + @State private var lastFallbackSpeed: CLLocationSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond + @State private var isImportedRoute = false + + private var mapDisplayMode: MapDisplayMode { + MapDisplayMode(rawValue: mapDisplayModeRawValue) ?? .standard + } + + private var mapPointsOfInterestMode: MapPointsOfInterestMode { + MapPointsOfInterestMode(rawValue: mapPointsOfInterestRawValue) ?? .all + } + + private var mapStyle: MapStyle { + switch mapDisplayMode { + case .standard: + return .standard( + elevation: .realistic, + pointsOfInterest: mapPointsOfInterestMode.categories, + showsTraffic: mapShowsTraffic + ) + case .satellite: + return .imagery(elevation: .realistic) + case .hybrid: + return .hybrid( + elevation: .realistic, + pointsOfInterest: mapPointsOfInterestMode.categories, + showsTraffic: mapShowsTraffic + ) + } + } + + /// 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 + } + + private var speedSettings: SpeedProfileSettings { + SpeedProfileSettings( + profile: speedProfile, + customSpeedMetersPerSecond: max(customSpeedKmh, 1) / 3.6 + ) + } + + private var speedProfileDetailText: String { + switch speedProfile { + case .custom: + return String(format: "%.0f km/h", max(customSpeedKmh, 1)) + case .bus: + return "Road speed, pauses at stops" + case .driving: + return "Road speed limits" + default: + if let fixed = speedProfile.fixedSpeedMetersPerSecond { + return String(format: "%.0f km/h", fixed * 3.6) + } + return "" + } + } + private var pairingFilePath: String { PairingFileStore.prepareURL().path } @@ -810,7 +1588,17 @@ struct LocationSimulationView: View { value: routePlan.distance / 1000, unit: UnitLength.kilometers ).formatted(.measurement(width: .abbreviated, usage: .road)) - let durationText = Self.routeDurationFormatter.string(from: routePlan.expectedTravelTime) + + let travelTime: TimeInterval + if let fixed = speedProfile.fixedSpeedMetersPerSecond { + travelTime = routePlan.distance / fixed + } else if speedProfile == .custom { + travelTime = routePlan.distance / speedSettings.customSpeedMetersPerSecond + } else { + travelTime = routePlan.expectedTravelTime + } + + let durationText = Self.routeDurationFormatter.string(from: travelTime) if let durationText, !durationText.isEmpty { return "\(distanceText) • ETA \(durationText)" } @@ -884,6 +1672,10 @@ struct LocationSimulationView: View { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } + 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) @@ -901,7 +1693,7 @@ struct LocationSimulationView: View { .tint(.red) } } - .mapStyle(.standard(elevation: .realistic)) + .mapStyle(mapStyle) .onTapGesture { point in if let loc = proxy.convert(point, from: .local) { applySelection(loc) @@ -910,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 @@ -951,6 +1746,8 @@ struct LocationSimulationView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { + mapStyleMenu + Button { showBookmarks = true } label: { @@ -997,6 +1794,14 @@ struct LocationSimulationView: View { } message: { Text("Enter a name for this location.") } + .alert("Custom Speed", isPresented: $showCustomSpeedPrompt) { + TextField("Speed (km/h)", text: $customSpeedInput) + .keyboardType(.decimalPad) + Button("Set") { applyCustomSpeedInput() } + Button("Cancel", role: .cancel) { } + } message: { + Text("Enter a playback speed in km/h.") + } .sheet(isPresented: $showBookmarks) { BookmarksView(bookmarks: $bookmarks) { bookmark in applySelection(bookmark.coordinate) @@ -1159,6 +1964,7 @@ struct LocationSimulationView: View { routeRequestID = UUID() setRoutePlan(nil) routePlaybackSamples = [] + routeBusStops = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false @@ -1166,7 +1972,7 @@ struct LocationSimulationView: View { let displayCoordinates = sampledRouteCoordinates( from: coordinates, - targetDistance: RouteSimulationDefaults.pathSamplingDistance + targetDistance: adaptiveSamplingDistance(for: coordinates) ) guard displayCoordinates.count > 1, @@ -1177,6 +1983,7 @@ struct LocationSimulationView: View { let distance = distanceAlong(displayCoordinates) let fallbackSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond + isImportedRoute = true routeStartSelection = RouteSearchSelection(title: "\(sourceName) Start", coordinate: firstCoordinate) routeEndSelection = RouteSearchSelection(title: "\(sourceName) End", coordinate: lastCoordinate) setRoutePlan(RouteSimulationPlan( @@ -1192,15 +1999,25 @@ struct LocationSimulationView: View { let requestID = UUID() routeRequestID = requestID isPrefetchingRouteSpeeds = true + lastFallbackSpeed = fallbackSpeed + let settings = speedSettings routeSpeedPrefetchTask = Task.detached(priority: .utility) { - let playbackSamples = await prefetchRoutePlaybackSamples( + let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, - fallbackSpeedMetersPerSecond: fallbackSpeed + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } ) guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } - routePlaybackSamples = playbackSamples + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops isPrefetchingRouteSpeeds = false } } @@ -1262,6 +2079,8 @@ struct LocationSimulationView: View { routeAttributionLink + speedProfileMenu + HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) @@ -1286,6 +2105,139 @@ struct LocationSimulationView: View { } } + private var mapStyleMenu: some View { + Menu { + Picker("Map Type", selection: $mapDisplayModeRawValue) { + ForEach(MapDisplayMode.allCases) { mode in + Label(mode.title, systemImage: mode.systemImage) + .tag(mode.rawValue) + } + } + + if mapDisplayMode.supportsOverlays { + Toggle(isOn: $mapShowsTraffic) { + Label("Traffic", systemImage: "car.2.fill") + } + + Picker("Points of Interest", selection: $mapPointsOfInterestRawValue) { + ForEach(MapPointsOfInterestMode.allCases) { mode in + Label(mode.title, systemImage: mode.systemImage) + .tag(mode.rawValue) + } + } + .pickerStyle(.menu) + } + } label: { + Image(systemName: "map.fill") + } + .accessibilityLabel("Map style: \(mapDisplayMode.title)") + } + + private var speedProfileMenu: some View { + Menu { + ForEach(SpeedProfile.allCases) { profile in + Button { + selectSpeedProfile(profile) + } label: { + if profile == speedProfile { + Label(profile.title, systemImage: "checkmark") + } else { + Label(profile.title, systemImage: profile.systemImage) + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: speedProfile.systemImage) + Text(speedProfile.title) + .font(.subheadline.weight(.medium)) + if !speedProfileDetailText.isEmpty { + Text(speedProfileDetailText) + .font(.caption) + .foregroundStyle(.secondary) + } + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + } + .buttonStyle(.bordered) + .tint(.blue) + .disabled(isBusy || isRouteRunning || isPrefetchingRouteSpeeds) + .accessibilityLabel("Playback speed: \(speedProfile.title)") + } + + private func selectSpeedProfile(_ profile: SpeedProfile) { + if profile == .custom { + customSpeedInput = String(format: "%.0f", max(customSpeedKmh, 1)) + showCustomSpeedPrompt = true + return + } + guard profile != speedProfile else { return } + let directionsChanged = profile.prefersWalkingDirections != speedProfile.prefersWalkingDirections + speedProfileRawValue = profile.rawValue + + // Searched routes can be re-planned along footpaths; imported routes keep + // their exact geometry and only get their pacing rebuilt. + if directionsChanged, + !isImportedRoute, + routeStartSelection != nil, + routeEndSelection != nil { + refreshRoute() + } else { + rebuildPlaybackSamplesForCurrentRoute() + } + } + + private func applyCustomSpeedInput() { + let normalized = customSpeedInput.replacingOccurrences(of: ",", with: ".") + guard let value = Double(normalized), value > 0 else { + alertTitle = "Invalid Speed" + alertMessage = "Enter a speed above 0 km/h." + showAlert = true + return + } + customSpeedKmh = min(value, 1000) + speedProfileRawValue = SpeedProfile.custom.rawValue + rebuildPlaybackSamplesForCurrentRoute() + } + + private func rebuildPlaybackSamplesForCurrentRoute() { + guard let routePlan, !isRouteRunning else { return } + + routeSpeedPrefetchTask?.cancel() + let requestID = UUID() + routeRequestID = requestID + isPrefetchingRouteSpeeds = true + + let displayCoordinates = routePlan.displayCoordinates + let fallbackSpeed = lastFallbackSpeed + let settings = speedSettings + + routeSpeedPrefetchTask = Task.detached(priority: .utility) { + let prefetch = await prefetchRoutePlaybackSamples( + displayCoordinates: displayCoordinates, + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } + ) + guard !Task.isCancelled else { return } + await MainActor.run { + guard routeRequestID == requestID else { return } + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops + isPrefetchingRouteSpeeds = false + } + } + } + private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } runLocationCommand( @@ -1420,6 +2372,7 @@ struct LocationSimulationView: View { routeStartSelection = nil routeEndSelection = nil routePlaybackSamples = [] + routeBusStops = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false @@ -1430,6 +2383,8 @@ struct LocationSimulationView: View { routeSpeedPrefetchTask?.cancel() setRoutePlan(nil) routePlaybackSamples = [] + routeBusStops = [] + isImportedRoute = false guard let routeStart = routeStartSelection?.coordinate, let routeEnd = routeEndSelection?.coordinate else { @@ -1447,7 +2402,7 @@ struct LocationSimulationView: View { request.source = MKMapItem(placemark: MKPlacemark(coordinate: routeStart)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: routeEnd)) request.requestsAlternateRoutes = false - request.transportType = .automobile + request.transportType = speedProfile.prefersWalkingDirections ? .walking : .automobile routeLoadTask = Task { do { @@ -1461,9 +2416,10 @@ struct LocationSimulationView: View { ) } + let routeCoordinates = route.polyline.coordinateArray let displayCoordinates = sampledRouteCoordinates( - from: route.polyline.coordinateArray, - targetDistance: RouteSimulationDefaults.pathSamplingDistance + from: routeCoordinates, + targetDistance: adaptiveSamplingDistance(for: routeCoordinates) ) let routePlan = RouteSimulationPlan( displayCoordinates: displayCoordinates, @@ -1487,16 +2443,26 @@ struct LocationSimulationView: View { await MainActor.run { guard routeRequestID == requestID else { return } + lastFallbackSpeed = fallbackSpeed + let settings = speedSettings routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = Task.detached(priority: .utility) { - let playbackSamples = await prefetchRoutePlaybackSamples( + let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, - fallbackSpeedMetersPerSecond: fallbackSpeed + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } ) guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } - routePlaybackSamples = playbackSamples + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops isPrefetchingRouteSpeeds = false } } diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index b2ef6a0e..836d8371 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @AppStorage(UserDefaults.Keys.confirmExternalJITRequests) private var confirmExternalJITRequests = true @AppStorage("keepAliveAudio") private var keepAliveAudio = true @AppStorage("keepAliveLocation") private var keepAliveLocation = true + @AppStorage("keepAppAliveBackground") private var keepAppAliveBackground = false @AppStorage(UserDefaults.Keys.targetDeviceIP) private var targetDeviceIP = DeviceConnectionContext.defaultTargetIPAddress @State private var isShowingPairingFilePicker = false @@ -105,8 +106,16 @@ struct SettingsView: View { .font(.caption).foregroundStyle(.secondary) } } - .onChange(of: keepAliveLocation) { _, enabled in - if !enabled { BackgroundLocationManager.shared.stop() } + .onChange(of: keepAliveLocation) { _, _ in + BackgroundLocationManager.shared.refreshFromSettings() + } + + Toggle(isOn: $keepAppAliveBackground) { + VStack(alignment: .leading, spacing: 2) { + Text("Hold App Alive in Background (Experimental)") + Text("Tapping an app keeps the debugger attached so iOS doesn't suspend it in the background. Enables JIT too. Higher battery use; can't revive an app iOS already closed.") + .font(.caption).foregroundStyle(.secondary) + } } } header: {