From c9f0ec92943bae52b722cea2b9ba216d7913ed39 Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:24:52 -0700 Subject: [PATCH 01/28] Update MapSelectionView.swift made the default speed walking speed --- StikDebug/Views/MapSelectionView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4d..457d89cf 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -318,7 +318,10 @@ private func buildPlaybackSamples( guard segmentDistance > 0 else { continue } let speedLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedWays) ?? fallbackSpeedMetersPerSecond - let clampedSpeed = max(speedLimit, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + let walkingCapMetersPerSecond: CLLocationSpeed = 1.4 + let cappedSpeed = min(speedLimit, walkingCapMetersPerSecond) + let clampedSpeed = max(cappedSpeed, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + let segmentTravelTime = segmentDistance / clampedSpeed let segmentStepCount = max(1, Int(ceil(segmentTravelTime / RouteSimulationDefaults.playbackTickInterval))) let stepDelay = segmentTravelTime / Double(segmentStepCount) From 07577f574ec656d772eb2c415cc9f25fb894e57d Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:27:04 -0700 Subject: [PATCH 02/28] Create swift.yml --- .github/workflows/swift.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 00000000..21ae770f --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,22 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: Swift + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v From e380f633b2442e916f55e35429fbfae6d1504249 Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:40:29 -0700 Subject: [PATCH 03/28] Update build_ipa.yml --- .github/workflows/build_ipa.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index 48c56d1c..e3d6d8f7 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -34,7 +34,7 @@ jobs: -configuration Debug \ -archivePath build/StikDebug.xcarchive \ -sdk iphoneos \ - -destination 'generic/platform=iOS' \ + -destination 'generic/platform=iOS,OS=17.4' \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ From c3cda9f1fd9caf7db20ee227c2e55492b7fd6b45 Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:53:33 -0700 Subject: [PATCH 04/28] Update build_ipa.yml --- .github/workflows/build_ipa.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index e3d6d8f7..ab3bc29d 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -24,7 +24,8 @@ jobs: - name: 2. Configure Xcode Environment uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.0.1' + xcode-version: '16.4' + - name: 3. Compile Project Archive run: | @@ -34,7 +35,7 @@ jobs: -configuration Debug \ -archivePath build/StikDebug.xcarchive \ -sdk iphoneos \ - -destination 'generic/platform=iOS,OS=17.4' \ + -destination 'generic/platform=iOS' \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ @@ -42,6 +43,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 . From 61f43e6bc01a716f72892ecdf03f3eac2c84d5bd Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:08:18 -0700 Subject: [PATCH 05/28] Update build_ipa.yml --- .github/workflows/build_ipa.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index ab3bc29d..da8f2efc 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -24,7 +24,8 @@ jobs: - name: 2. Configure Xcode Environment uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.6.0' + - name: 3. Compile Project Archive From 7880604f2d9c9515d814695381c84896205b2d66 Mon Sep 17 00:00:00 2001 From: dizzafizza Date: Mon, 13 Jul 2026 22:01:41 -0700 Subject: [PATCH 06/28] Add speed profiles for route playback (walking/jogging/cycling/driving/bus/custom) Adds a SpeedProfile picker to route simulation with fixed paces for walking, jogging, and cycling; road-speed-limit-based driving; a capped bus profile that slows on approach to and dwells at Overpass bus_stop nodes; and a custom km/h speed entered by the user. Also extends OverpassResponse.Element with lat/lon so bus stop nodes (which report coordinates directly rather than via geometry) can be parsed. --- StikDebug/Views/MapSelectionView.swift | 363 +++++++++++++++++++++++-- 1 file changed, 344 insertions(+), 19 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 457d89cf..d60c29a2 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -52,6 +52,88 @@ private struct RoutePlaybackSample { 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 + } + } +} + +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 @@ -70,6 +152,8 @@ private struct OverpassResponse: Decodable { struct Element: Decodable { let tags: [String: String]? let geometry: [Coordinate]? + let lat: Double? + let lon: Double? } struct Coordinate: Decodable { @@ -205,7 +289,7 @@ private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocatio return directionalValues.min() } -private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? { +private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBusStops: Bool) -> String? { guard let first = coordinates.first else { return nil } var minLatitude = first.latitude @@ -228,19 +312,26 @@ private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) + let busStopClause = includeBusStops ? "\n node(\(bbox))[highway=bus_stop];" : "" + return """ [out:json][timeout:20]; ( way(\(bbox))[highway][maxspeed]; way(\(bbox))[highway]["maxspeed:forward"]; - way(\(bbox))[highway]["maxspeed:backward"]; + way(\(bbox))[highway]["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 fetchRouteSpeedContext( + for coordinates: [CLLocationCoordinate2D], + includeBusStops: Bool +) async throws -> RouteSpeedContext { + guard let query = overpassQuery(for: coordinates, includeBusStops: includeBusStops) else { + return RouteSpeedContext(ways: [], busStops: []) + } var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "data", value: query)] @@ -258,7 +349,8 @@ private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) a } let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) - return decoded.elements.compactMap { element in + + let ways: [OpenStreetMapWay] = decoded.elements.compactMap { element in guard let tags = element.tags, let speedLimit = speedLimitMetersPerSecond(from: tags), let geometry = element.geometry?.map({ CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }), @@ -271,6 +363,16 @@ private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) a speedLimitMetersPerSecond: speedLimit ) } + + let busStops: [CLLocationCoordinate2D] = decoded.elements.compactMap { element in + guard let lat = element.lat, let lon = element.lon, + element.tags?["highway"] == "bus_stop" else { + return nil + } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + return RouteSpeedContext(ways: ways, busStops: busStops) } private func nearestSpeedLimit( @@ -305,8 +407,9 @@ private func nearestSpeedLimit( private func buildPlaybackSamples( from displayCoordinates: [CLLocationCoordinate2D], - speedWays: [OpenStreetMapWay], - fallbackSpeedMetersPerSecond: CLLocationSpeed + speedContext: RouteSpeedContext, + fallbackSpeedMetersPerSecond: CLLocationSpeed, + speedSettings: SpeedProfileSettings ) -> [RoutePlaybackSample] { guard let firstCoordinate = displayCoordinates.first else { return [] } @@ -317,11 +420,24 @@ 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 walkingCapMetersPerSecond: CLLocationSpeed = 1.4 - let cappedSpeed = min(speedLimit, walkingCapMetersPerSecond) - let clampedSpeed = max(cappedSpeed, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + let roadLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedContext.ways) + 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) + let midLocation = CLLocation(latitude: midpoint.latitude, longitude: midpoint.longitude) + let nearStop = speedContext.busStops.contains { stop in + midLocation.distance(from: CLLocation(latitude: stop.latitude, longitude: stop.longitude)) + <= SpeedProfileSettings.busStopApproachRadius + } + if nearStop { + 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) @@ -338,18 +454,66 @@ 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 { + var dwellIndices: Set = [] + for stop in speedContext.busStops { + let stopLocation = CLLocation(latitude: stop.latitude, longitude: stop.longitude) + var bestIndex: Int? + var bestDistance = SpeedProfileSettings.busStopSnapRadius + for (index, sample) in samples.enumerated() { + 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 func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], - fallbackSpeedMetersPerSecond: CLLocationSpeed + fallbackSpeedMetersPerSecond: CLLocationSpeed, + speedSettings: SpeedProfileSettings ) async -> [RoutePlaybackSample] { - let speedWays = (try? await fetchOpenStreetMapWays(for: displayCoordinates)) ?? [] + let needsRoadData = speedSettings.profile.fixedSpeedMetersPerSecond == nil + && speedSettings.profile != .custom + let context: RouteSpeedContext + if needsRoadData { + context = (try? await fetchRouteSpeedContext( + for: displayCoordinates, + includeBusStops: speedSettings.profile == .bus + )) ?? RouteSpeedContext(ways: [], busStops: []) + } else { + // Fixed/custom pace: no need to bother Overpass at all. + context = RouteSpeedContext(ways: [], busStops: []) + } return buildPlaybackSamples( from: displayCoordinates, - speedWays: speedWays, - fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond + speedContext: context, + fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond, + speedSettings: speedSettings ) } @@ -770,6 +934,41 @@ struct LocationSimulationView: View { @State private var showSaveBookmark = false @State private var newBookmarkName = "" + // 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 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 } @@ -813,7 +1012,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)" } @@ -1000,6 +1209,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) @@ -1180,6 +1397,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( @@ -1195,10 +1413,13 @@ struct LocationSimulationView: View { let requestID = UUID() routeRequestID = requestID isPrefetchingRouteSpeeds = true + lastFallbackSpeed = fallbackSpeed + let settings = speedSettings routeSpeedPrefetchTask = Task.detached(priority: .utility) { let playbackSamples = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, - fallbackSpeedMetersPerSecond: fallbackSpeed + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings ) guard !Task.isCancelled else { return } await MainActor.run { @@ -1265,6 +1486,8 @@ struct LocationSimulationView: View { routeAttributionLink + speedProfileMenu + HStack(spacing: 12) { Button("Stop", action: clear) .buttonStyle(.bordered) @@ -1289,6 +1512,104 @@ struct LocationSimulationView: View { } } + 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 playbackSamples = await prefetchRoutePlaybackSamples( + displayCoordinates: displayCoordinates, + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings + ) + guard !Task.isCancelled else { return } + await MainActor.run { + guard routeRequestID == requestID else { return } + routePlaybackSamples = playbackSamples + isPrefetchingRouteSpeeds = false + } + } + } + private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } runLocationCommand( @@ -1433,6 +1754,7 @@ struct LocationSimulationView: View { routeSpeedPrefetchTask?.cancel() setRoutePlan(nil) routePlaybackSamples = [] + isImportedRoute = false guard let routeStart = routeStartSelection?.coordinate, let routeEnd = routeEndSelection?.coordinate else { @@ -1450,7 +1772,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 { @@ -1490,11 +1812,14 @@ 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( displayCoordinates: displayCoordinates, - fallbackSpeedMetersPerSecond: fallbackSpeed + fallbackSpeedMetersPerSecond: fallbackSpeed, + speedSettings: settings ) guard !Task.isCancelled else { return } await MainActor.run { From 69d7a1d9cc384bb68fed954f1590670e420b9181 Mon Sep 17 00:00:00 2001 From: sandbox Date: Tue, 14 Jul 2026 05:06:43 +0000 Subject: [PATCH 07/28] Fix build: return RouteSpeedContext, not [], in fetchRouteSpeedContext Leftover empty-array return from the pre-refactor signature caused 'cannot convert [Any] to RouteSpeedContext' at MapSelectionView.swift:338. --- StikDebug/Views/MapSelectionView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index d60c29a2..95528813 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -335,7 +335,7 @@ private func fetchRouteSpeedContext( var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "data", value: query)] - guard let url = components?.url else { return [] } + guard let url = components?.url else { return RouteSpeedContext(ways: [], busStops: []) } let (data, response) = try await URLSession.shared.data(from: url) From f848bca707c53b4348337b89696276c6960e272c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:39:06 +0000 Subject: [PATCH 08/28] Add map style options to location simulator (satellite, hybrid, traffic, transit stops) - New toolbar menu on the location simulator map to switch between Standard, Satellite, and Hybrid map types - Traffic overlay toggle for Standard/Hybrid modes - Points-of-interest mode: All Places, Transit Stops (surfaces bus stops), or Hidden - Selections persist across launches via AppStorage Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CUJbpUQiRUbjiTiYA4v7UK --- StikDebug/Views/MapSelectionView.swift | 125 ++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 95528813..37b213f2 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -103,6 +103,67 @@ enum SpeedProfile: String, CaseIterable, Identifiable { } } +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 @@ -934,6 +995,11 @@ 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 @@ -942,6 +1008,33 @@ struct LocationSimulationView: View { @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 + ) + } + } + private var speedProfile: SpeedProfile { SpeedProfile(rawValue: speedProfileRawValue) ?? .driving } @@ -1113,7 +1206,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) @@ -1163,6 +1256,8 @@ struct LocationSimulationView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { + mapStyleMenu + Button { showBookmarks = true } label: { @@ -1512,6 +1607,34 @@ 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 From 8ea835e291cbd925b7a2c74ad0903beb5663ddd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:49:25 +0000 Subject: [PATCH 09/28] Show bus stops on the map in bus simulator mode The bus stops fetched from OpenStreetMap for bus-mode pacing/dwell were only used to compute playback timing and never drawn. Surface them from the prefetch result into view state and render them as orange bus markers whenever the Bus speed profile is active. --- StikDebug/Views/MapSelectionView.swift | 35 ++++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 37b213f2..7cede05c 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -553,11 +553,16 @@ private func buildPlaybackSamples( return samples } +private struct RoutePlaybackPrefetchResult { + let samples: [RoutePlaybackSample] + let busStops: [CLLocationCoordinate2D] +} + private func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], fallbackSpeedMetersPerSecond: CLLocationSpeed, speedSettings: SpeedProfileSettings -) async -> [RoutePlaybackSample] { +) async -> RoutePlaybackPrefetchResult { let needsRoadData = speedSettings.profile.fixedSpeedMetersPerSecond == nil && speedSettings.profile != .custom let context: RouteSpeedContext @@ -570,12 +575,13 @@ private func prefetchRoutePlaybackSamples( // Fixed/custom pace: no need to bother Overpass at all. context = RouteSpeedContext(ways: [], busStops: []) } - return buildPlaybackSamples( + let samples = buildPlaybackSamples( from: displayCoordinates, speedContext: context, fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond, speedSettings: speedSettings ) + return RoutePlaybackPrefetchResult(samples: samples, busStops: context.busStops) } private enum CoordinateImportError: LocalizedError { @@ -977,6 +983,7 @@ 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 routePlaybackCoordinate: CLLocationCoordinate2D? @State private var simulatedCoordinate: CLLocationCoordinate2D? @State private var routeRequestID = UUID() @@ -1189,6 +1196,12 @@ struct LocationSimulationView: View { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } + if speedProfile == .bus { + ForEach(Array(routeBusStops.enumerated()), id: \.offset) { _, stop in + Marker("Bus Stop", systemImage: "bus.fill", coordinate: stop) + .tint(.orange) + } + } if let routeStartCoordinate { Marker("Start", coordinate: routeStartCoordinate) .tint(.green) @@ -1474,6 +1487,7 @@ struct LocationSimulationView: View { routeRequestID = UUID() setRoutePlan(nil) routePlaybackSamples = [] + routeBusStops = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false @@ -1511,7 +1525,7 @@ struct LocationSimulationView: View { lastFallbackSpeed = fallbackSpeed let settings = speedSettings routeSpeedPrefetchTask = Task.detached(priority: .utility) { - let playbackSamples = await prefetchRoutePlaybackSamples( + let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, speedSettings: settings @@ -1519,7 +1533,8 @@ struct LocationSimulationView: View { guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } - routePlaybackSamples = playbackSamples + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops isPrefetchingRouteSpeeds = false } } @@ -1719,7 +1734,7 @@ struct LocationSimulationView: View { let settings = speedSettings routeSpeedPrefetchTask = Task.detached(priority: .utility) { - let playbackSamples = await prefetchRoutePlaybackSamples( + let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, speedSettings: settings @@ -1727,7 +1742,8 @@ struct LocationSimulationView: View { guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } - routePlaybackSamples = playbackSamples + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops isPrefetchingRouteSpeeds = false } } @@ -1867,6 +1883,7 @@ struct LocationSimulationView: View { routeStartSelection = nil routeEndSelection = nil routePlaybackSamples = [] + routeBusStops = [] routePlaybackCoordinate = nil isLoadingRoute = false isPrefetchingRouteSpeeds = false @@ -1877,6 +1894,7 @@ struct LocationSimulationView: View { routeSpeedPrefetchTask?.cancel() setRoutePlan(nil) routePlaybackSamples = [] + routeBusStops = [] isImportedRoute = false guard let routeStart = routeStartSelection?.coordinate, @@ -1939,7 +1957,7 @@ struct LocationSimulationView: View { let settings = speedSettings routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = Task.detached(priority: .utility) { - let playbackSamples = await prefetchRoutePlaybackSamples( + let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, speedSettings: settings @@ -1947,7 +1965,8 @@ struct LocationSimulationView: View { guard !Task.isCancelled else { return } await MainActor.run { guard routeRequestID == requestID else { return } - routePlaybackSamples = playbackSamples + routePlaybackSamples = prefetch.samples + routeBusStops = prefetch.busStops isPrefetchingRouteSpeeds = false } } From 3e3fc5ebc56a312ce21da2638270f1fe479897da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:55:32 +0000 Subject: [PATCH 10/28] Catch bus stops mapped with the public_transport schema Previously only OSM nodes tagged highway=bus_stop were fetched and drawn, which misses most stops in regions that use the newer public_transport schema (platform / stop_position with bus=yes). Query and decode all of these tag combinations so bus mode surfaces the full set of stops instead of the sparse few tagged the legacy way. --- StikDebug/Views/MapSelectionView.swift | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 7cede05c..deeab337 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -373,7 +373,16 @@ private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBus let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) - let busStopClause = includeBusStops ? "\n node(\(bbox))[highway=bus_stop];" : "" + // 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`). Query all of them so we don't miss most stops in areas that use + // the modern tagging. + let busStopClause = includeBusStops ? """ + + node(\(bbox))[highway=bus_stop]; + node(\(bbox))[public_transport=platform][bus=yes]; + node(\(bbox))[public_transport=stop_position][bus=yes]; + """ : "" return """ [out:json][timeout:20]; @@ -386,6 +395,17 @@ private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBus """ } +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 +} + private func fetchRouteSpeedContext( for coordinates: [CLLocationCoordinate2D], includeBusStops: Bool @@ -427,7 +447,8 @@ private func fetchRouteSpeedContext( let busStops: [CLLocationCoordinate2D] = decoded.elements.compactMap { element in guard let lat = element.lat, let lon = element.lon, - element.tags?["highway"] == "bus_stop" else { + let tags = element.tags, + tagsDescribeBusStop(tags) else { return nil } return CLLocationCoordinate2D(latitude: lat, longitude: lon) From 984a94a031ea2c24c26d2f83b9d30a887590aa72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 07:56:59 +0000 Subject: [PATCH 11/28] Speed up route preparation for long routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long routes took minutes to prepare (or appeared stuck) because of three compounding costs, each now fixed: - Overpass fetch: the query covered the route's whole bounding box, so a long or diagonal route pulled every speed-limited road in a huge rectangle. Replace it with an 'around' corridor query over a downsampled route line (75 m spacing, capped at 700 points), so the payload scales with route length, not bounding area. POST the query (corridor strings outgrow GET URLs) with an explicit 30 s timeout so a slow server can no longer stall route prep indefinitely — failures fall back to profile/ETA pacing as before. - Speed-limit matching: nearestSpeedLimit rescanned every OSM way segment for every route segment (O(route x ways), tens of millions of distance checks on long routes). Add RouteSpeedIndex, a grid-bucketed spatial index (~440 m cells) probed via its 3x3 neighborhood; every search radius is well below one cell, so results are unchanged. The bus-stop approach check and the dwell snapping pass (previously O(stops x samples)) use the same bucketing. - Route densification: 10 m sampling made a 300 km route produce 30k+ points on the main thread. Sampling now widens adaptively past a 25k point cap; playback interpolates per tick within segments anyway, so smoothness is unaffected. --- StikDebug/Views/MapSelectionView.swift | 350 ++++++++++++++++++++----- 1 file changed, 279 insertions(+), 71 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index deeab337..54f90098 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -42,11 +42,25 @@ 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 @@ -203,8 +217,38 @@ 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 + + // Corridor query tuning. Rather than a bounding box over the whole route + // (which balloons for long/diagonal routes and pulls in every road in the + // rectangle), we query `around` a downsampled version of the route so the + // payload scales with the route's length, not its bounding area. + static let corridorMinSpacing: CLLocationDistance = 75 // downsample step + static let corridorMaxPoints = 700 // cap query size + static let corridorWayRadius: CLLocationDistance = 60 // road search radius + static let corridorBusStopRadius: CLLocationDistance = 90 // stop search radius + static let requestTimeout: TimeInterval = 30 + + // 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 { @@ -350,46 +394,83 @@ private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocatio return directionalValues.min() } -private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBusStops: Bool) -> String? { - guard let first = coordinates.first else { return nil } +/// Reduce a dense (≈10 m) route to a sparse set of points spaced at least +/// `minimumSpacing` apart, capped at `maximumPointCount`. Used to keep the +/// Overpass `around` query small while still tracing the whole route. +private func corridorCoordinates( + from coordinates: [CLLocationCoordinate2D], + minimumSpacing: CLLocationDistance, + maximumPointCount: Int +) -> [CLLocationCoordinate2D] { + guard coordinates.count > 2 else { return coordinates } + + let totalDistance = distanceAlong(coordinates) + let spacing = max(minimumSpacing, totalDistance / Double(max(1, maximumPointCount - 1))) - var minLatitude = first.latitude - var maxLatitude = first.latitude - var minLongitude = first.longitude - var maxLongitude = first.longitude + var result: [CLLocationCoordinate2D] = [coordinates[0]] + var accumulated: CLLocationDistance = 0 + var previous = coordinates[0] for coordinate in coordinates.dropFirst() { - minLatitude = min(minLatitude, coordinate.latitude) - maxLatitude = max(maxLatitude, coordinate.latitude) - minLongitude = min(minLongitude, coordinate.longitude) - maxLongitude = max(maxLongitude, coordinate.longitude) + accumulated += CLLocation(latitude: previous.latitude, longitude: previous.longitude) + .distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + previous = coordinate + if accumulated >= spacing { + result.append(coordinate) + accumulated = 0 + } + } + + if let last = coordinates.last, + result.last.map(CoordinateSnapshot.init) != CoordinateSnapshot(last) { + result.append(last) } - let padding = OpenStreetMapSpeedLimitService.boundingBoxPaddingDegrees - let south = minLatitude - padding - let west = minLongitude - padding - let north = maxLatitude + padding - let east = maxLongitude + padding + return result +} + +private func overpassCorridorString(_ coordinates: [CLLocationCoordinate2D]) -> String { + coordinates + .map { String(format: "%.5f,%.5f", $0.latitude, $0.longitude) } + .joined(separator: ",") +} - let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) +private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBusStops: Bool) -> String? { + let corridor = corridorCoordinates( + from: coordinates, + minimumSpacing: OpenStreetMapSpeedLimitService.corridorMinSpacing, + maximumPointCount: OpenStreetMapSpeedLimitService.corridorMaxPoints + ) + guard !corridor.isEmpty else { return nil } + + let coordString = overpassCorridorString(corridor) + let wayRadius = Int(OpenStreetMapSpeedLimitService.corridorWayRadius.rounded()) // 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`). Query all of them so we don't miss most stops in areas that use // the modern tagging. - let busStopClause = includeBusStops ? """ + var busStopClause = "" + if includeBusStops { + let stopRadius = Int(OpenStreetMapSpeedLimitService.corridorBusStopRadius.rounded()) + busStopClause = """ - node(\(bbox))[highway=bus_stop]; - node(\(bbox))[public_transport=platform][bus=yes]; - node(\(bbox))[public_transport=stop_position][bus=yes]; - """ : "" + node(around:\(stopRadius),\(coordString))[highway=bus_stop]; + node(around:\(stopRadius),\(coordString))[public_transport=platform][bus=yes]; + node(around:\(stopRadius),\(coordString))[public_transport=stop_position][bus=yes]; + """ + } + // `around` selects only elements near the route corridor, so the response + // stays small even for long routes instead of covering the whole bounding + // rectangle. return """ - [out:json][timeout:20]; + [out:json][timeout:25]; + way(around:\(wayRadius),\(coordString))[highway]->.roads; ( - way(\(bbox))[highway][maxspeed]; - way(\(bbox))[highway]["maxspeed:forward"]; - way(\(bbox))[highway]["maxspeed:backward"];\(busStopClause) + way.roads[maxspeed]; + way.roads["maxspeed:forward"]; + way.roads["maxspeed:backward"];\(busStopClause) ); out tags geom; """ @@ -414,11 +495,21 @@ private func fetchRouteSpeedContext( return RouteSpeedContext(ways: [], busStops: []) } - var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "data", value: query)] - guard let url = components?.url else { return RouteSpeedContext(ways: [], busStops: []) } + // POST the query: corridor queries grow with route length and can exceed + // URL limits as a GET. The explicit timeout keeps a slow Overpass server + // from stalling route prep indefinitely — on failure the caller falls back + // to profile/ETA pacing. + var request = URLRequest(url: OpenStreetMapSpeedLimitService.endpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = OpenStreetMapSpeedLimitService.requestTimeout - let (data, response) = try await URLSession.shared.data(from: url) + 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) { @@ -457,34 +548,125 @@ private func fetchRouteSpeedContext( return RouteSpeedContext(ways: ways, busStops: busStops) } -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) - ) +private struct IndexedWaySegment { + let start: MKMapPoint + let end: MKMapPoint + let speedLimitMetersPerSecond: CLLocationSpeed +} - if bestMatch == nil || candidateDistance < bestMatch!.distance { - bestMatch = (way.speedLimitMetersPerSecond, candidateDistance) +/// 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)) + // A real road segment spans at most a couple of cells; a huge + // span means broken data (e.g. antimeridian jump) — skip it + // rather than flooding the index. + guard maxX - minX <= 8, maxY - minY <= 8 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) + } } - guard let bestMatch, - bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { - return nil + 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( @@ -495,6 +677,7 @@ private func buildPlaybackSamples( ) -> [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()) { @@ -502,19 +685,17 @@ private func buildPlaybackSamples( .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) guard segmentDistance > 0 else { continue } - let roadLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedContext.ways) + 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) - let midLocation = CLLocation(latitude: midpoint.latitude, longitude: midpoint.longitude) - let nearStop = speedContext.busStops.contains { stop in - midLocation.distance(from: CLLocation(latitude: stop.latitude, longitude: stop.longitude)) - <= SpeedProfileSettings.busStopApproachRadius - } - if nearStop { + if speedIndex.hasBusStop( + within: SpeedProfileSettings.busStopApproachRadius, + of: midpoint + ) { speed = min(speed, SpeedProfileSettings.busStopApproachSpeed) } } @@ -538,21 +719,47 @@ 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 (index, sample) in samples.enumerated() { - let distance = stopLocation.distance( - from: CLLocation( - latitude: sample.coordinate.latitude, - longitude: sample.coordinate.longitude - ) - ) - if distance <= bestDistance { - bestDistance = distance - bestIndex = index + 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 { @@ -1516,7 +1723,7 @@ struct LocationSimulationView: View { let displayCoordinates = sampledRouteCoordinates( from: coordinates, - targetDistance: RouteSimulationDefaults.pathSamplingDistance + targetDistance: adaptiveSamplingDistance(for: coordinates) ) guard displayCoordinates.count > 1, @@ -1948,9 +2155,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, From 656ec6d512629771aba15a0c7e1d39cef4622868 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 08:59:20 +0000 Subject: [PATCH 12/28] Keep JIT/debug sessions alive when backgrounded and support cellular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When you enable JIT for an app and switch to it (e.g. a game), StikDebug gets backgrounded. If iOS suspends it, the debug connection it holds — and the marco/polo heartbeat that keeps that connection trusted — goes idle, the device tears it down, and the app loses its session after a while. The app also assumed Wi-Fi even though the tunnel rides LocalDevVPN and works on cellular. Session survival (backgrounding): - DebugKeepAliveLease now forces the silent-audio and background-location keep-alive on for the whole debug session, regardless of the user's keep-alive toggles, so a session survives being switched out even if those toggles are off. - Stop the background-task expiration handler from tearing the whole keep-alive down. It used to call invalidate() (killing audio/location) the moment the ~30s assertion elapsed; now it just renews the assertion while the silent-audio keep-alive continues to sustain background execution, so a long backgrounded session keeps running instead of dropping. - Play an inaudible non-zero signal instead of digital silence so iOS does not treat the audio session as idle and reclaim it (suspending the app). Cellular support: - Add NetworkPathMonitor (NWPathMonitor) so the app observes the real path (Wi-Fi, cellular, or VPN) instead of assuming Wi-Fi. - Retry the tunnel connect with backoff so transient cellular failures recover instead of erroring, skipping permanent errors (bad/missing pairing file, invalid target IP). - Reconnect the tunnel on network-path changes (Wi-Fi<->cellular handoff). - Remove the false "Wi-Fi required" wording from the connection alerts and the README. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qznh32XffG6vwFLMcwm1Mq --- README.md | 3 +- StikDebug/App/AppBootstrapper.swift | 1 + .../Services/BackgroundAudioManager.swift | 44 ++++++-- .../Services/BackgroundLocationManager.swift | 40 +++++-- StikDebug/Services/DebugKeepAliveLease.swift | 54 +++++++--- StikDebug/Services/NetworkPathMonitor.swift | 101 ++++++++++++++++++ StikDebug/Services/TunnelManager.swift | 101 +++++++++++++++--- .../Support/NotificationName+StikDebug.swift | 3 + StikDebug/Views/SettingsView.swift | 4 +- 9 files changed, 310 insertions(+), 41 deletions(-) create mode 100644 StikDebug/Services/NetworkPathMonitor.swift 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..2b3368c4 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() { 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/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index b2ef6a0e..52d1040e 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -105,8 +105,8 @@ struct SettingsView: View { .font(.caption).foregroundStyle(.secondary) } } - .onChange(of: keepAliveLocation) { _, enabled in - if !enabled { BackgroundLocationManager.shared.stop() } + .onChange(of: keepAliveLocation) { _, _ in + BackgroundLocationManager.shared.refreshFromSettings() } } header: { From 84a5a5eff6652fd196c1e41b6c934571bd9253de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:53:28 +0000 Subject: [PATCH 13/28] Add experimental "hold app alive in background" mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets the real problem: an app (e.g. Roblox) getting closed/disconnected by iOS once it's in the background. iOS won't let one app keep another alive — except that a process a debugger is attached to is not suspended on backgrounding (the same reason an Xcode-run app keeps executing in the background). This uses that. - JITEnableContext.keepAppAlive: launch the app suspended, vAttach (which sets CS_DEBUGGED so JIT is on and marks the process debugger-owned), disable its memory limit to reduce background jetsam kills, send a raw continue so it runs, then hold the debug connection open with a heartbeat until the user stops it — then interrupt + detach so the app keeps running on its own. The hold is a single-threaded sleep-poll (no blocking socket reads, no concurrent handle access), so it's cleanly cancellable. - BackgroundAliveManager + HoldToken: own one active hold, keep StikDebug itself alive via DebugKeepAliveLease for the duration, publish the active app name. - UI: an experimental Settings toggle ("Hold App Alive in Background"); when on, tapping an app in Home holds it alive instead of a normal one-shot JIT, and a banner with a Stop button appears while a hold is active. Caveats (documented in-app): experimental and unverified on-device; requires StikDebug to stay running, so it's best-effort not bulletproof; cannot revive an app iOS already killed; higher battery use. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qznh32XffG6vwFLMcwm1Mq --- StikDebug/App/AppBootstrapper.swift | 3 +- StikDebug/Device/JITEnableContext.swift | 99 +++++++++++++++ .../Services/BackgroundAliveManager.swift | 115 ++++++++++++++++++ StikDebug/Views/HomeView.swift | 60 ++++++++- StikDebug/Views/SettingsView.swift | 9 ++ 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 StikDebug/Services/BackgroundAliveManager.swift diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 2b3368c4..4ff12d50 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -24,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..45b74662 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -642,6 +642,105 @@ final class JITEnableContext { } } + /// Launches an app, attaches the debugger, 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. + /// + /// - Note: This keeps StikDebug's connection to *this* app alive; it cannot + /// revive an app iOS has already killed. Requires StikDebug itself to stay + /// running (see `DebugKeepAliveLease`). + func keepAppAlive(withBundleID bundleID: String, cancellation: HoldToken, logger: LogFunc?) -> Bool { + 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, 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, 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) + + let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" + let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) + + // Keep the RSD/lockdown tunnel trusted for the whole hold. + let heartbeat = try? connectHeartbeatKeepAlive(logger: logger) + try? heartbeat?.start() + defer { heartbeat?.stop() } + + // Continue the (suspended-launched) process so it runs. Sent raw and not + // awaited: a debugger-owned, running process is not stopped by iOS when + // backgrounded, so there are no stop replies to drain — we just hold the + // connection open. Polling a flag (rather than blocking on a socket read) + // keeps this single-threaded and cleanly cancellable. + sendContinue(debugProxy) + emitLog("Keep-alive: app is running and held under the debugger", logger: logger) + + while !cancellation.isCancelled { + Thread.sleep(forTimeInterval: 0.5) + } + + // 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) + } + } + + private func sendContinue(_ debugProxy: OpaquePointer) { + // "$c#63" — RSP continue-all-threads packet (valid in no-ack mode). + var packet: [UInt8] = [0x24, 0x63, 0x23, 0x36, 0x33] + _ = debug_proxy_send_raw(debugProxy, &packet, packet.count) + } + func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { do { try ensureTunnel() diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift new file mode 100644 index 00000000..eab079b0 --- /dev/null +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -0,0 +1,115 @@ +// +// 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 + } + + func start(bundleID: String, displayName: String?) { + lock.lock() + guard activeBundleID == nil else { + lock.unlock() + return + } + let token = HoldToken() + 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, + 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/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index a6478532..c9404093 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,19 @@ struct HomeView: View { InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle Haptics.medium() - startJITInBackground(bundleID: selectedBundle, displayName: selectedName) + if keepAppAliveBackground { + startKeepAlive(bundleID: selectedBundle, displayName: selectedName) + } else { + startJITInBackground(bundleID: selectedBundle, displayName: selectedName) + } }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + .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 +56,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 +252,50 @@ 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 + } + let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) + AccessibilityAnnouncer.announce(startingMessage) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName) + } + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index 52d1040e..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 @@ -109,6 +110,14 @@ struct SettingsView: View { 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: { Text("Background Keep-Alive") } From abcc0cfc78bc1dd745f9bb5337da72d7fe0b802d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:03:23 +0000 Subject: [PATCH 14/28] Fix build: convert packet.count to UInt for debug_proxy_send_raw debug_proxy_send_raw expects a UInt (uintptr_t) length; packet.count is Int, which does not auto-convert. Archive failed with "cannot convert value of type 'Int' to expected argument type 'UInt'". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Qznh32XffG6vwFLMcwm1Mq --- StikDebug/Device/JITEnableContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index 45b74662..a63c5e7f 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -738,7 +738,7 @@ final class JITEnableContext { private func sendContinue(_ debugProxy: OpaquePointer) { // "$c#63" — RSP continue-all-threads packet (valid in no-ack mode). var packet: [UInt8] = [0x24, 0x63, 0x23, 0x36, 0x33] - _ = debug_proxy_send_raw(debugProxy, &packet, packet.count) + _ = debug_proxy_send_raw(debugProxy, &packet, UInt(packet.count)) } func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { From 2ee7a0c71f56f6c1eb3b47c9d3f9b913b95e48cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:25:49 +0000 Subject: [PATCH 15/28] Fix cryptic error for pairing files missing required fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When rp_pairing_file_read fails to parse the local pairing plist (e.g. an older/incompatible file missing public_key), the raw Serde/plist debug string was surfaced as a generic "Connection Error" with a useless "Try Again" button — retrying a local parse failure can never succeed. Reclassify this failure to the same error code used for invalid pairing files so it routes to the existing "Select New File" prompt instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DAnD7odyVzX36xY1vvZcJL --- StikDebug/Device/JITEnableContext.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index a63c5e7f..94484869 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -126,7 +126,8 @@ final class JITEnableContext { } if let ffiError { - throw error(from: ffiError, fallback: "Failed to read pairing file!") + let readError = error(from: ffiError, fallback: "Failed to read pairing file!") + throw Self.isMalformedPairingFile(readError) ? malformedPairingFileError(readError) : readError } guard let pairingFile else { @@ -136,6 +137,24 @@ final class JITEnableContext { return pairingFile } + /// `rp_pairing_file_read` only parses the local plist on disk — no network + /// involved — so a Serde/plist error here always means the file's contents + /// are the wrong shape (e.g. an older pairing file missing `public_key`), + /// not a transient failure a retry would fix. Reclassifying it to the same + /// code as an invalid pairing file routes it to the "select a new file" + /// prompt instead of the generic connection-error/retry flow. + private static func isMalformedPairingFile(_ error: NSError) -> Bool { + let message = error.localizedDescription.lowercased() + return message.contains("missing field") || message.contains("serde(") || message.contains("plist(error") + } + + private func malformedPairingFileError(_ underlying: NSError) -> NSError { + makeError( + "The pairing file is missing data StikDebug needs (\(underlying.localizedDescription)). It's likely the wrong format or an incomplete export — select a new pairing file.", + code: -9 + ) + } + private func createTunnel(hostname: String) throws -> TunnelHandles { let pairingFile = try getPairingFile() defer { rp_pairing_file_free(pairingFile) } From dad59fab0cf22debf695999b58d3906c79d1d11f Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:31:14 -0700 Subject: [PATCH 16/28] Revert "Fix cryptic error for pairing files missing required fields" --- StikDebug/Device/JITEnableContext.swift | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index 94484869..a63c5e7f 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -126,8 +126,7 @@ final class JITEnableContext { } if let ffiError { - let readError = error(from: ffiError, fallback: "Failed to read pairing file!") - throw Self.isMalformedPairingFile(readError) ? malformedPairingFileError(readError) : readError + throw error(from: ffiError, fallback: "Failed to read pairing file!") } guard let pairingFile else { @@ -137,24 +136,6 @@ final class JITEnableContext { return pairingFile } - /// `rp_pairing_file_read` only parses the local plist on disk — no network - /// involved — so a Serde/plist error here always means the file's contents - /// are the wrong shape (e.g. an older pairing file missing `public_key`), - /// not a transient failure a retry would fix. Reclassifying it to the same - /// code as an invalid pairing file routes it to the "select a new file" - /// prompt instead of the generic connection-error/retry flow. - private static func isMalformedPairingFile(_ error: NSError) -> Bool { - let message = error.localizedDescription.lowercased() - return message.contains("missing field") || message.contains("serde(") || message.contains("plist(error") - } - - private func malformedPairingFileError(_ underlying: NSError) -> NSError { - makeError( - "The pairing file is missing data StikDebug needs (\(underlying.localizedDescription)). It's likely the wrong format or an incomplete export — select a new pairing file.", - code: -9 - ) - } - private func createTunnel(hostname: String) throws -> TunnelHandles { let pairingFile = try getPairingFile() defer { rp_pairing_file_free(pairingFile) } From 7b1df353cb2dde171bd78c31d337e0eea8b5425d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:19:01 +0000 Subject: [PATCH 17/28] Remove Swift Package CI workflow that can never pass swift.yml runs 'swift build' / 'swift test', which require a Package.swift. This repo is an Xcode app project with no Swift package, so the workflow has failed with 'Could not find Package.swift' on every run since it was added, marking every push and PR with a failing check. The Build Debug IPA workflow already compiles the project via xcodebuild on the same triggers, so build validation is unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- .github/workflows/swift.yml | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .github/workflows/swift.yml diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml deleted file mode 100644 index 21ae770f..00000000 --- a/.github/workflows/swift.yml +++ /dev/null @@ -1,22 +0,0 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift - -name: Swift - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v From e11a6062d11f490bf8f5340efbdb4712f64885af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:26:11 +0000 Subject: [PATCH 18/28] Index long rural road segments instead of dropping them The route-speed spatial index skipped any OSM way segment whose bounding box spanned more than 8 cells (~3.5 km). Sparsely-noded rural highways can legitimately run much farther between geometry nodes, so those stretches were dropped from the index and silently fell back to average route pacing instead of their real speed limit. Raise the guard to 128 cells (~57 km at the equator), which still catches genuinely broken data such as antimeridian jumps (~90,000 cells) while keeping realistic long segments in the index. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Views/MapSelectionView.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 54f90098..d4f54f78 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -579,10 +579,16 @@ private struct RouteSpeedIndex { 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)) - // A real road segment spans at most a couple of cells; a huge - // span means broken data (e.g. antimeridian jump) — skip it - // rather than flooding the index. - guard maxX - minX <= 8, maxY - minY <= 8 else { continue } + // 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) From b394334d8ceed7fdc5c2a6839e2eaf9fa8089af2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:27:16 +0000 Subject: [PATCH 19/28] Add fork code-review report Documents the review of all fork changes since upstream divergence, the two defects fixed (broken swift.yml CI workflow, over-aggressive speed-index segment cap), and the areas verified as correct. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- CODE_REVIEW_REPORT.md | 151 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 CODE_REVIEW_REPORT.md diff --git a/CODE_REVIEW_REPORT.md b/CODE_REVIEW_REPORT.md new file mode 100644 index 00000000..a540e8be --- /dev/null +++ b/CODE_REVIEW_REPORT.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. From 5e8c4bdcd4602bafdefc6185e7aaf33f98246c02 Mon Sep 17 00:00:00 2001 From: James <113275743+dizzafizza@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:02:18 -0700 Subject: [PATCH 20/28] Rename CODE_REVIEW_REPORT.md to CODE_REVIEW_REPORT_BY_FABLE_5.md Added model details --- CODE_REVIEW_REPORT.md => CODE_REVIEW_REPORT_BY_FABLE_5.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CODE_REVIEW_REPORT.md => CODE_REVIEW_REPORT_BY_FABLE_5.md (100%) diff --git a/CODE_REVIEW_REPORT.md b/CODE_REVIEW_REPORT_BY_FABLE_5.md similarity index 100% rename from CODE_REVIEW_REPORT.md rename to CODE_REVIEW_REPORT_BY_FABLE_5.md From dcf7c6d0cfde59e54a982f90bf4c40f09fd3416b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:23:08 +0000 Subject: [PATCH 21/28] Load bus stops in chunks so long routes stop timing out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route corridor was sent to Overpass as one all-or-nothing query that embedded the coordinate string four times (roads + three bus-stop tag clauses). On long routes that regularly exceeded the 25s server timeout, and because the caller swallowed errors, every bus stop and speed limit was silently lost — exactly the 'stops never load' failure. The 700-point corridor cap also widened point spacing far beyond the 90m search radius on very long routes, cutting corners past stops even when the query succeeded. Rework the fetch into independent corridor chunks (~12km each, two in flight, per-chunk timeouts): - Each chunk is a small, fast request; a failed chunk only loses its own stretch instead of the whole route, and failures are logged. - Bus stops stream onto the map as chunks arrive via an onPartialBusStops callback instead of appearing only at the end. - The three bus-stop tag clauses are collapsed into one regex clause (a single corridor pass server-side); exact filtering stays client-side in tagsDescribeBusStop. - Results are deduplicated across chunk seams by OSM element id, and stops mapped twice (platform + stop_position a few meters apart) are merged spatially so markers and dwells aren't doubled. - Corridor spacing now stays within the search radius up to ~285km of route (24 chunks x 160 points) instead of degrading past ~50km. Verified the exact generated query shape against the live Overpass API: HTTP 200 in ~2s, element ids/geometry/lat-lon match the decoder, and the regex clause returns both legacy and public_transport-schema stops. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Views/MapSelectionView.swift | 267 +++++++++++++++++++++---- 1 file changed, 224 insertions(+), 43 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index d4f54f78..2fe9b234 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -219,15 +219,25 @@ private enum OpenStreetMapSpeedLimitService { static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! static let nearestWayThreshold: CLLocationDistance = 40 - // Corridor query tuning. Rather than a bounding box over the whole route - // (which balloons for long/diagonal routes and pulls in every road in the - // rectangle), we query `around` a downsampled version of the route so the - // payload scales with the route's length, not its bounding area. - static let corridorMinSpacing: CLLocationDistance = 75 // downsample step - static let corridorMaxPoints = 700 // cap query size - static let corridorWayRadius: CLLocationDistance = 60 // road search radius + // Corridor query tuning. Rather than one all-or-nothing query over the + // whole route (which embedded the corridor once per clause and timed out on + // long routes, losing every stop and speed limit at once), the corridor is + // split into small chunks fetched as independent requests: each is cheap + // enough to finish well inside its timeout, a failed chunk only loses its + // own stretch of road, and bus stops stream onto the map as chunks arrive. + static let corridorMinSpacing: CLLocationDistance = 75 // downsample step + static let chunkMaxPoints = 160 // ≈12 km of route per request + static let maxChunkCount = 24 // spacing widens past ~285 km + static let maxConcurrentChunkRequests = 2 // public Overpass allows 2 slots/IP + static let corridorWayRadius: CLLocationDistance = 60 // road search radius static let corridorBusStopRadius: CLLocationDistance = 90 // stop search radius - static let requestTimeout: TimeInterval = 30 + static let chunkServerTimeout: TimeInterval = 15 // Overpass-side [timeout:] + static let requestTimeout: TimeInterval = 20 // client-side, per chunk + + // 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 @@ -255,6 +265,7 @@ private struct OverpassResponse: Decodable { let elements: [Element] struct Element: Decodable { + let id: Int? let tags: [String: String]? let geometry: [Coordinate]? let lat: Double? @@ -435,12 +446,33 @@ private func overpassCorridorString(_ coordinates: [CLLocationCoordinate2D]) -> .joined(separator: ",") } -private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBusStops: Bool) -> String? { +/// Downsample the route once, then split the corridor into chunks that each +/// fit a small, fast Overpass request. Consecutive chunks share one boundary +/// point, so an element near a seam is always within at least one chunk's +/// search radius (chunk overlap + per-element dedup keep results exact). +private func corridorChunks(from coordinates: [CLLocationCoordinate2D]) -> [[CLLocationCoordinate2D]] { let corridor = corridorCoordinates( from: coordinates, minimumSpacing: OpenStreetMapSpeedLimitService.corridorMinSpacing, - maximumPointCount: OpenStreetMapSpeedLimitService.corridorMaxPoints + maximumPointCount: OpenStreetMapSpeedLimitService.chunkMaxPoints + * OpenStreetMapSpeedLimitService.maxChunkCount ) + guard !corridor.isEmpty else { return [] } + + let chunkSize = OpenStreetMapSpeedLimitService.chunkMaxPoints + guard corridor.count > chunkSize else { return [corridor] } + + var chunks: [[CLLocationCoordinate2D]] = [] + var start = 0 + while start < corridor.count - 1 { + let end = min(start + chunkSize, corridor.count) + chunks.append(Array(corridor[start.. String? { guard !corridor.isEmpty else { return nil } let coordString = overpassCorridorString(corridor) @@ -448,24 +480,22 @@ private func overpassQuery(for coordinates: [CLLocationCoordinate2D], includeBus // 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`). Query all of them so we don't miss most stops in areas that use - // the modern tagging. + // `bus=yes`). One regex clause pulls that superset in a single `around` + // pass — repeating the corridor once per tag variant tripled the server-side + // cost — and `tagsDescribeBusStop` keeps only real bus stops client-side. var busStopClause = "" if includeBusStops { let stopRadius = Int(OpenStreetMapSpeedLimitService.corridorBusStopRadius.rounded()) busStopClause = """ - node(around:\(stopRadius),\(coordString))[highway=bus_stop]; - node(around:\(stopRadius),\(coordString))[public_transport=platform][bus=yes]; - node(around:\(stopRadius),\(coordString))[public_transport=stop_position][bus=yes]; + node(around:\(stopRadius),\(coordString))[~"^(highway|public_transport)$"~"^(bus_stop|platform|stop_position)$"]; """ } - // `around` selects only elements near the route corridor, so the response - // stays small even for long routes instead of covering the whole bounding - // rectangle. + // `around` selects only elements near this stretch of the route, so each + // chunk's response stays small regardless of total route length. return """ - [out:json][timeout:25]; + [out:json][timeout:\(Int(OpenStreetMapSpeedLimitService.chunkServerTimeout))]; way(around:\(wayRadius),\(coordString))[highway]->.roads; ( way.roads[maxspeed]; @@ -487,18 +517,22 @@ private func tagsDescribeBusStop(_ tags: [String: String]) -> Bool { return false } -private func fetchRouteSpeedContext( - for coordinates: [CLLocationCoordinate2D], +private struct OverpassChunkResult { + let ways: [(id: Int, value: OpenStreetMapWay)] + let busStops: [(id: Int, value: CLLocationCoordinate2D)] +} + +private func fetchSpeedContextChunk( + corridor: [CLLocationCoordinate2D], includeBusStops: Bool -) async throws -> RouteSpeedContext { - guard let query = overpassQuery(for: coordinates, includeBusStops: includeBusStops) else { - return RouteSpeedContext(ways: [], busStops: []) +) async throws -> OverpassChunkResult { + guard let query = overpassChunkQuery(for: corridor, includeBusStops: includeBusStops) else { + return OverpassChunkResult(ways: [], busStops: []) } - // POST the query: corridor queries grow with route length and can exceed - // URL limits as a GET. The explicit timeout keeps a slow Overpass server - // from stalling route prep indefinitely — on failure the caller falls back - // to profile/ETA pacing. + // POST the query: corridor strings can exceed URL limits as a GET. 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") @@ -522,30 +556,157 @@ private func fetchRouteSpeedContext( let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) - let ways: [OpenStreetMapWay] = decoded.elements.compactMap { element in - guard let tags = element.tags, + 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: [CLLocationCoordinate2D] = decoded.elements.compactMap { element in - guard let lat = element.lat, let lon = element.lon, + 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 CLLocationCoordinate2D(latitude: lat, longitude: lon) + return (id, CLLocationCoordinate2D(latitude: lat, longitude: lon)) } - return RouteSpeedContext(ways: ways, busStops: busStops) + return OverpassChunkResult(ways: ways, busStops: busStops) +} + +/// 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 +} + +/// Fetches road speed limits (and optionally bus stops) along the route. +/// +/// The corridor is fetched as independent chunks, at most +/// `maxConcurrentChunkRequests` in flight, deduplicated by OSM element id +/// (chunks overlap at their seams). Individual chunk failures are tolerated — +/// whatever data arrived 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 chunks = corridorChunks(from: coordinates) + guard !chunks.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 = chunks.makeIterator() + var inFlight = 0 + while inFlight < OpenStreetMapSpeedLimitService.maxConcurrentChunkRequests, + let chunk = pending.next() { + group.addTask { try? await fetchSpeedContextChunk(corridor: chunk, includeBusStops: includeBusStops) } + inFlight += 1 + } + + for await result in group { + if Task.isCancelled { + group.cancelAll() + break + } + if let chunk = pending.next() { + group.addTask { try? await fetchSpeedContextChunk(corridor: chunk, 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 includeBusStops, addedStops { + onPartialBusStops?(dedupedBusStops( + busStops, + radius: OpenStreetMapSpeedLimitService.busStopDedupeRadius + )) + } + } + } + + if failedChunkCount > 0 { + LogManager.shared.addWarningLog( + "Route data: \(failedChunkCount) of \(chunks.count) map-data chunks failed; continuing with partial coverage" + ) + } + + return RouteSpeedContext( + ways: ways, + busStops: dedupedBusStops( + busStops, + radius: OpenStreetMapSpeedLimitService.busStopDedupeRadius + ) + ) } private struct IndexedWaySegment { @@ -795,16 +956,18 @@ private struct RoutePlaybackPrefetchResult { private func prefetchRoutePlaybackSamples( displayCoordinates: [CLLocationCoordinate2D], fallbackSpeedMetersPerSecond: CLLocationSpeed, - speedSettings: SpeedProfileSettings + speedSettings: SpeedProfileSettings, + onPartialBusStops: (@Sendable ([CLLocationCoordinate2D]) -> Void)? = nil ) async -> RoutePlaybackPrefetchResult { let needsRoadData = speedSettings.profile.fixedSpeedMetersPerSecond == nil && speedSettings.profile != .custom let context: RouteSpeedContext if needsRoadData { - context = (try? await fetchRouteSpeedContext( + context = await fetchRouteSpeedContext( for: displayCoordinates, - includeBusStops: speedSettings.profile == .bus - )) ?? RouteSpeedContext(ways: [], busStops: []) + includeBusStops: speedSettings.profile == .bus, + onPartialBusStops: onPartialBusStops + ) } else { // Fixed/custom pace: no need to bother Overpass at all. context = RouteSpeedContext(ways: [], busStops: []) @@ -1762,7 +1925,13 @@ struct LocationSimulationView: View { let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, - speedSettings: settings + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } ) guard !Task.isCancelled else { return } await MainActor.run { @@ -1971,7 +2140,13 @@ struct LocationSimulationView: View { let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, - speedSettings: settings + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } ) guard !Task.isCancelled else { return } await MainActor.run { @@ -2195,7 +2370,13 @@ struct LocationSimulationView: View { let prefetch = await prefetchRoutePlaybackSamples( displayCoordinates: displayCoordinates, fallbackSpeedMetersPerSecond: fallbackSpeed, - speedSettings: settings + speedSettings: settings, + onPartialBusStops: { stops in + Task { @MainActor in + guard routeRequestID == requestID else { return } + routeBusStops = stops + } + } ) guard !Task.isCancelled else { return } await MainActor.run { From a7792d0289b2f5ada302711146f1240a51536877 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 05:02:46 +0000 Subject: [PATCH 22/28] Switch route data fetch from around() to bbox: fixes inconsistent bus stops Bus stops loaded inconsistently even after the previous chunking fix. Investigation against the live Overpass API found two compounding root causes: 1. Overpass's around(radius, point-list) operator costs scale with points-in-list x candidate-elements-in-area. A single chunk at the previous chunkMaxPoints=160 reliably timed out; even much smaller chunks (25-60 points) took anywhere from 3s to 23s depending on how road-dense the area was -- the same chunk size that was fine in a quiet area could time out in a busy one, which is exactly the "sometimes works, sometimes doesn't" behavior reported. 2. When Overpass's own [timeout:N] elapses, it replies HTTP 200 with an empty elements array and a "remark" field describing the error -- not an error status. The client only checked the HTTP status code, so a timed-out chunk was silently indistinguishable from "no stops near this stretch of road" and never retried or logged. Fixes: - Replace the around() query with a bounding-box query per chunk. A bbox test is an O(1) rectangle check per candidate regardless of route shape or point density, unlike around(). Verified live: a chunk that took 16-23s (or timed out) via around() resolved via bbox in 2-8s for the same area, including a deliberately pathological zigzag route whose bbox came out much larger than the route itself. - Chunk the route by real-world distance (~5km) instead of point count, since query cost no longer depends on point density -- this also simplifies chunk building (no separate query-string downsampling step). - Decode the "remark" field and treat any non-nil value as a failure so a server-side timeout is retried and logged instead of silently accepted as empty data. - Retry each chunk once with a short backoff before giving up, since the public Overpass instance is a shared, load-dependent resource where a single slow response shouldn't cost that stretch of road its data. Verified the exact query shape and decoder assumptions against the live Overpass API across multiple route shapes (realistic 5km stretch, dense-area chunk, pathological zigzag) -- all resolved in under 8s with correct bus stop / speed limit extraction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Views/MapSelectionView.swift | 249 +++++++++++++++---------- 1 file changed, 152 insertions(+), 97 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 2fe9b234..56f5a10e 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -219,20 +219,30 @@ private enum OpenStreetMapSpeedLimitService { static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! static let nearestWayThreshold: CLLocationDistance = 40 - // Corridor query tuning. Rather than one all-or-nothing query over the - // whole route (which embedded the corridor once per clause and timed out on - // long routes, losing every stop and speed limit at once), the corridor is - // split into small chunks fetched as independent requests: each is cheap - // enough to finish well inside its timeout, a failed chunk only loses its - // own stretch of road, and bus stops stream onto the map as chunks arrive. - static let corridorMinSpacing: CLLocationDistance = 75 // downsample step - static let chunkMaxPoints = 160 // ≈12 km of route per request - static let maxChunkCount = 24 // spacing widens past ~285 km - static let maxConcurrentChunkRequests = 2 // public Overpass allows 2 slots/IP - static let corridorWayRadius: CLLocationDistance = 60 // road search radius - static let corridorBusStopRadius: CLLocationDistance = 90 // stop search radius - static let chunkServerTimeout: TimeInterval = 15 // Overpass-side [timeout:] - static let requestTimeout: TimeInterval = 20 // client-side, per chunk + // 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 @@ -263,6 +273,12 @@ private struct SpatialCellKey: Hashable { 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? @@ -405,98 +421,103 @@ private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocatio return directionalValues.min() } -/// Reduce a dense (≈10 m) route to a sparse set of points spaced at least -/// `minimumSpacing` apart, capped at `maximumPointCount`. Used to keep the -/// Overpass `around` query small while still tracing the whole route. -private func corridorCoordinates( - from coordinates: [CLLocationCoordinate2D], - minimumSpacing: CLLocationDistance, - maximumPointCount: Int -) -> [CLLocationCoordinate2D] { - guard coordinates.count > 2 else { return coordinates } +/// 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 spacing = max(minimumSpacing, totalDistance / Double(max(1, maximumPointCount - 1))) + let chunkDistance = max( + OpenStreetMapSpeedLimitService.chunkTargetDistance, + totalDistance / Double(OpenStreetMapSpeedLimitService.maxChunkCount) + ) - var result: [CLLocationCoordinate2D] = [coordinates[0]] + var chunks: [[CLLocationCoordinate2D]] = [] + var current: [CLLocationCoordinate2D] = [coordinates[0]] var accumulated: CLLocationDistance = 0 - var previous = coordinates[0] - for coordinate in coordinates.dropFirst() { + for (previous, point) in zip(coordinates, coordinates.dropFirst()) { accumulated += CLLocation(latitude: previous.latitude, longitude: previous.longitude) - .distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) - previous = coordinate - if accumulated >= spacing { - result.append(coordinate) + .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 let last = coordinates.last, - result.last.map(CoordinateSnapshot.init) != CoordinateSnapshot(last) { - result.append(last) + if current.count > 1 { + chunks.append(current) } - - return result + return chunks } -private func overpassCorridorString(_ coordinates: [CLLocationCoordinate2D]) -> String { - coordinates - .map { String(format: "%.5f,%.5f", $0.latitude, $0.longitude) } - .joined(separator: ",") +private struct ChunkBoundingBox { + let south: Double + let west: Double + let north: Double + let east: Double } -/// Downsample the route once, then split the corridor into chunks that each -/// fit a small, fast Overpass request. Consecutive chunks share one boundary -/// point, so an element near a seam is always within at least one chunk's -/// search radius (chunk overlap + per-element dedup keep results exact). -private func corridorChunks(from coordinates: [CLLocationCoordinate2D]) -> [[CLLocationCoordinate2D]] { - let corridor = corridorCoordinates( - from: coordinates, - minimumSpacing: OpenStreetMapSpeedLimitService.corridorMinSpacing, - maximumPointCount: OpenStreetMapSpeedLimitService.chunkMaxPoints - * OpenStreetMapSpeedLimitService.maxChunkCount - ) - guard !corridor.isEmpty else { return [] } +/// 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 } - let chunkSize = OpenStreetMapSpeedLimitService.chunkMaxPoints - guard corridor.count > chunkSize else { return [corridor] } + var minLatitude = first.latitude + var maxLatitude = first.latitude + var minLongitude = first.longitude + var maxLongitude = first.longitude - var chunks: [[CLLocationCoordinate2D]] = [] - var start = 0 - while start < corridor.count - 1 { - let end = min(start + chunkSize, corridor.count) - chunks.append(Array(corridor[start.. String? { - guard !corridor.isEmpty else { return nil } + let midLatitudeRadians = (minLatitude + maxLatitude) / 2 * .pi / 180 + let latitudePadding = paddingMeters / 111_320 + let longitudePadding = paddingMeters / (111_320 * max(cos(midLatitudeRadians), 0.01)) - let coordString = overpassCorridorString(corridor) - let wayRadius = Int(OpenStreetMapSpeedLimitService.corridorWayRadius.rounded()) + 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 `around` - // pass — repeating the corridor once per tag variant tripled the server-side - // cost — and `tagsDescribeBusStop` keeps only real bus stops client-side. + // `bus=yes`). One regex clause pulls that superset in a single pass — + // `tagsDescribeBusStop` keeps only real bus stops client-side. var busStopClause = "" if includeBusStops { - let stopRadius = Int(OpenStreetMapSpeedLimitService.corridorBusStopRadius.rounded()) busStopClause = """ - node(around:\(stopRadius),\(coordString))[~"^(highway|public_transport)$"~"^(bus_stop|platform|stop_position)$"]; + node(\(bboxString))[~"^(highway|public_transport)$"~"^(bus_stop|platform|stop_position)$"]; """ } - // `around` selects only elements near this stretch of the route, so each - // chunk's response stays small regardless of total route length. + // 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:\(Int(OpenStreetMapSpeedLimitService.chunkServerTimeout))]; - way(around:\(wayRadius),\(coordString))[highway]->.roads; + way(\(bboxString))[highway]->.roads; ( way.roads[maxspeed]; way.roads["maxspeed:forward"]; @@ -523,16 +544,15 @@ private struct OverpassChunkResult { } private func fetchSpeedContextChunk( - corridor: [CLLocationCoordinate2D], + bbox: ChunkBoundingBox, includeBusStops: Bool ) async throws -> OverpassChunkResult { - guard let query = overpassChunkQuery(for: corridor, includeBusStops: includeBusStops) else { - return OverpassChunkResult(ways: [], busStops: []) - } + let query = overpassChunkQuery(forBoundingBox: bbox, includeBusStops: includeBusStops) - // POST the query: corridor strings can exceed URL limits as a GET. The - // explicit timeout keeps a slow Overpass server from stalling this chunk — - // a failed chunk only costs its own stretch of road data. + // 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") @@ -556,6 +576,18 @@ private func fetchSpeedContextChunk( let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) + // 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, @@ -584,6 +616,27 @@ private func fetchSpeedContextChunk( 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. @@ -632,20 +685,22 @@ private func dedupedBusStops( /// Fetches road speed limits (and optionally bus stops) along the route. /// -/// The corridor is fetched as independent chunks, at most -/// `maxConcurrentChunkRequests` in flight, deduplicated by OSM element id -/// (chunks overlap at their seams). Individual chunk failures are tolerated — -/// whatever data arrived 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. +/// 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 chunks = corridorChunks(from: coordinates) - guard !chunks.isEmpty else { return RouteSpeedContext(ways: [], busStops: []) } + 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] = [] @@ -654,11 +709,11 @@ private func fetchRouteSpeedContext( var failedChunkCount = 0 await withTaskGroup(of: OverpassChunkResult?.self) { group in - var pending = chunks.makeIterator() + var pending = bboxes.makeIterator() var inFlight = 0 while inFlight < OpenStreetMapSpeedLimitService.maxConcurrentChunkRequests, - let chunk = pending.next() { - group.addTask { try? await fetchSpeedContextChunk(corridor: chunk, includeBusStops: includeBusStops) } + let bbox = pending.next() { + group.addTask { await fetchSpeedContextChunkWithRetry(bbox: bbox, includeBusStops: includeBusStops) } inFlight += 1 } @@ -667,8 +722,8 @@ private func fetchRouteSpeedContext( group.cancelAll() break } - if let chunk = pending.next() { - group.addTask { try? await fetchSpeedContextChunk(corridor: chunk, includeBusStops: includeBusStops) } + if let bbox = pending.next() { + group.addTask { await fetchSpeedContextChunkWithRetry(bbox: bbox, includeBusStops: includeBusStops) } } guard let result else { failedChunkCount += 1 @@ -696,7 +751,7 @@ private func fetchRouteSpeedContext( if failedChunkCount > 0 { LogManager.shared.addWarningLog( - "Route data: \(failedChunkCount) of \(chunks.count) map-data chunks failed; continuing with partial coverage" + "Route data: \(failedChunkCount) of \(bboxes.count) map-data chunks failed after retrying; continuing with partial coverage" ) } From d2f09bea32489c7e7521b4e1e9a104a1167f49c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 05:38:41 +0000 Subject: [PATCH 23/28] Honor "Hidden" points of interest for bus stops and declutter at low zoom Bus stop markers are our own Marker overlay drawn from Overpass data, not Apple's native POI layer, so setting Points of Interest to "Hidden" (which only sets MapStyle's pointsOfInterest category filter) had no effect on them -- they kept showing regardless of the setting. Separately, a bus route can have dozens of stops close together; zoomed out they overlap into an unreadable stack of pins (see reported screenshot: a wall of stacked bus icons across a multi-city view). Fixes: - Gate the bus stop markers on mapPointsOfInterestMode != .hidden so "Hidden" actually hides them. - Track the map's visible latitude span via onMapCameraChange and thin markers through the existing dedupedBusStops spatial merge, using a spacing that scales with the visible span -- full detail at close zoom, progressively fewer markers as the map zooms out. The full-fidelity routeBusStops list (used for dwell pacing during playback) is untouched; only the on-screen marker set is thinned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Views/MapSelectionView.swift | 35 ++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 56f5a10e..4364f831 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -1436,6 +1436,7 @@ struct LocationSimulationView: View { @State private var routePolyline: MKPolyline? @State private var routePlaybackSamples: [RoutePlaybackSample] = [] @State private var routeBusStops: [CLLocationCoordinate2D] = [] + @State private var visibleLatitudeDelta: CLLocationDegrees = 0.05 @State private var routePlaybackCoordinate: CLLocationCoordinate2D? @State private var simulatedCoordinate: CLLocationCoordinate2D? @State private var routeRequestID = UUID() @@ -1494,6 +1495,29 @@ struct LocationSimulationView: View { } } + /// Bus stop markers to actually draw on the map. Two adjustments on top of + /// the raw fetched `routeBusStops`: + /// + /// - Honors "Points of Interest: Hidden" — the stops are drawn as our own + /// Marker overlay, not Apple's native POI layer, so Apple's + /// `pointsOfInterest` style option (set from the same picker) has no + /// effect on them; that had to be handled here explicitly. + /// - Thins markers as the map zooms out, since a bus route easily has + /// dozens of stops that overlap into an unreadable stack of pins at a + /// city-wide zoom. Spacing scales with the visible latitude span so + /// roughly the same number of markers are visible on screen at any + /// zoom level; the full-fidelity `routeBusStops` list (used for dwell + /// pacing during playback) is untouched. + private var displayedBusStops: [CLLocationCoordinate2D] { + guard speedProfile == .bus, mapPointsOfInterestMode != .hidden else { return [] } + let visibleSpanMeters = visibleLatitudeDelta * 111_320 + let minimumSpacing = max( + SpeedProfileSettings.busStopSnapRadius, + visibleSpanMeters / 25 + ) + return dedupedBusStops(routeBusStops, radius: minimumSpacing) + } + private var speedProfile: SpeedProfile { SpeedProfile(rawValue: speedProfileRawValue) ?? .driving } @@ -1648,11 +1672,9 @@ struct LocationSimulationView: View { MapPolyline(routePolyline) .stroke(.blue.opacity(0.8), lineWidth: 5) } - if speedProfile == .bus { - ForEach(Array(routeBusStops.enumerated()), id: \.offset) { _, stop in - Marker("Bus Stop", systemImage: "bus.fill", coordinate: stop) - .tint(.orange) - } + ForEach(Array(displayedBusStops.enumerated()), id: \.offset) { _, stop in + Marker("Bus Stop", systemImage: "bus.fill", coordinate: stop) + .tint(.orange) } if let routeStartCoordinate { Marker("Start", coordinate: routeStartCoordinate) @@ -1680,6 +1702,9 @@ struct LocationSimulationView: View { .mapControls { MapCompass() } + .onMapCameraChange(frequency: .onEnd) { context in + visibleLatitudeDelta = context.region.span.latitudeDelta + } } .ignoresSafeArea() .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in From 42951c6e105b8ba4451f781d770e6196f5403e2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 06:01:15 +0000 Subject: [PATCH 24/28] Run the JIT script in hold-alive mode so attached apps get working JIT The experimental "Hold App Alive in Background" mode attached the debugger (setting CS_DEBUGGED) but never ran the app's assigned JIT script. On TXM / iOS 26+ devices JIT is not enabled merely by attaching: the app requests executable memory at runtime via `brk #0xf00d` syscalls that the debugger must service, which is exactly what the JIT script does in its breakpoint loop. Without the script those syscalls went unhandled, so the app trapped and hung (or JIT silently failed) on its first JIT call -- "the JIT script doesn't run when attaching to an app." Thread the same JIT-script callback the normal debug path uses through the hold flow: - HomeView.startKeepAlive resolves the app's preferred script (gated on hasTXM, exactly like startJITInBackground) and passes it down. - BackgroundAliveManager.start and JITEnableContext.keepAppAlive forward it to holdDebugSession. - holdDebugSession, when a script is present, runs it (the script does its own vAttach + JIT enable + continue and services the app's JIT syscalls for as long as the app uses JIT, keeping the app alive under the debugger). When absent it keeps the previous attach-and-hold behavior for older devices where CS_DEBUGGED alone enables JIT. The normal (non-hold) JIT path is unchanged. Known limitation: for an app whose script runs continuously, the hold ends when the app is closed (matching the normal flow, which likewise cannot interrupt a running script); the Stop button fully applies to the no-script hold path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DNqCsHShA4yNqgZ6ZZQy3 --- StikDebug/Device/JITEnableContext.swift | 71 +++++++++++++++---- .../Services/BackgroundAliveManager.swift | 6 +- StikDebug/Views/HomeView.swift | 10 ++- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index a63c5e7f..c1db201e 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -642,8 +642,9 @@ final class JITEnableContext { } } - /// Launches an app, attaches the debugger, and *holds* the session open so the - /// app keeps running in the background. + /// Launches an app, attaches the debugger, runs the app's assigned JIT + /// script (if any), and *holds* the session open so the app keeps running + /// in the background. /// /// A debugger-owned process is not suspended by iOS when it moves to the /// background (the same reason an app run from Xcode keeps executing while @@ -652,10 +653,14 @@ final class JITEnableContext { /// background jetsam kill. The hold runs until `cancellation` is set, then we /// interrupt and detach so the app continues running on its own. /// + /// - Parameter script: The same JIT-script callback the normal debug path + /// uses. When present it runs once (doing its own attach + JIT enable + + /// continue) before the hold begins, so scripts work in hold mode too; + /// when `nil` the app is simply attached and continued. /// - Note: This keeps StikDebug's connection to *this* app alive; it cannot /// revive an app iOS has already killed. Requires StikDebug itself to stay /// running (see `DebugKeepAliveLease`). - func keepAppAlive(withBundleID bundleID: String, cancellation: HoldToken, logger: LogFunc?) -> Bool { + func keepAppAlive(withBundleID bundleID: String, script: DebugAppCallback?, cancellation: HoldToken, logger: LogFunc?) -> Bool { do { try withConnectedDebugSession { remoteServer, debugProxy in let pid = try withProcessControl(remoteServer: remoteServer) { processControl in @@ -677,7 +682,14 @@ final class JITEnableContext { return Int32(truncatingIfNeeded: pid) } - try holdDebugSession(pid: pid, debugProxy: debugProxy, logger: logger, cancellation: cancellation) + try holdDebugSession( + pid: pid, + debugProxy: debugProxy, + remoteServer: remoteServer, + script: script, + logger: logger, + cancellation: cancellation + ) } emitLog("Keep-alive session ended for \(bundleID)", logger: logger) @@ -688,7 +700,14 @@ final class JITEnableContext { } } - private func holdDebugSession(pid: Int32, debugProxy: OpaquePointer, logger: LogFunc?, cancellation: HoldToken) throws { + private func holdDebugSession( + pid: Int32, + debugProxy: OpaquePointer, + remoteServer: OpaquePointer, + script: DebugAppCallback?, + logger: LogFunc?, + cancellation: HoldToken + ) throws { debug_proxy_send_ack(debugProxy) debug_proxy_send_ack(debugProxy) @@ -701,23 +720,47 @@ final class JITEnableContext { debug_proxy_set_ack_mode(debugProxy, 0) - let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" - let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" - emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) - // Keep the RSD/lockdown tunnel trusted for the whole hold. let heartbeat = try? connectHeartbeatKeepAlive(logger: logger) try? heartbeat?.start() defer { heartbeat?.stop() } - // Continue the (suspended-launched) process so it runs. Sent raw and not - // awaited: a debugger-owned, running process is not stopped by iOS when - // backgrounded, so there are no stop replies to drain — we just hold the - // connection open. Polling a flag (rather than blocking on a socket read) - // keeps this single-threaded and cleanly cancellable. + if let script { + // TXM / iOS 26+ JIT: the app requests executable memory at runtime via + // `brk #0xf00d` syscalls that the debugger must service. That is exactly + // what the JIT script does — it attaches (`vAttach`, enabling JIT), + // then loops handling those breakpoints for as long as the app uses + // JIT, which also keeps the app alive under the debugger the whole + // time. Merely attaching without running the script (the old hold + // behavior) left those syscalls unhandled, so the app trapped and hung + // on its first JIT call — the bug this fixes. + // + // The script issues its own attach/continue via `send_command`, so we + // must NOT send `vAttach` here (that would double-attach). It runs + // until the app detaches or exits; the semaphore blocks until then. + // StikDebug itself is held alive across this by the caller's + // `DebugKeepAliveLease`. + emitLog("Keep-alive: running JIT script (services JIT and holds the app)", logger: logger) + let semaphore = DispatchSemaphore(value: 0) + script(pid, debugProxy, remoteServer, semaphore) + semaphore.wait() + emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger) + return + } + + // No script (older devices where JIT is enabled simply by CS_DEBUGGED): + // attach to set CS_DEBUGGED, then continue so the app runs. Sent raw and + // not awaited: a debugger-owned, running process is not stopped by iOS + // when backgrounded, so there are no stop replies to drain — we just hold + // the connection open until the user stops it. + let attachCommand = "vAttach;\(String(UInt32(bitPattern: pid), radix: 16))" + let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) sendContinue(debugProxy) emitLog("Keep-alive: app is running and held under the debugger", logger: logger) + // Polling a flag (rather than blocking on a socket read) keeps this + // single-threaded and cleanly cancellable. while !cancellation.isCancelled { Thread.sleep(forTimeInterval: 0.5) } diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index eab079b0..3c0ae8f9 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -48,7 +48,10 @@ final class BackgroundAliveManager: ObservableObject { return activeBundleID != nil } - func start(bundleID: String, displayName: String?) { + /// - Parameter script: Optional JIT-script callback to run once (after + /// attach, before the hold begins) so the app's assigned script executes + /// in hold mode just like it does for a normal JIT run. + func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil) { lock.lock() guard activeBundleID == nil else { lock.unlock() @@ -76,6 +79,7 @@ final class BackgroundAliveManager: ObservableObject { let succeeded = JITEnableContext.shared.keepAppAlive( withBundleID: bundleID, + script: script, cancellation: token, logger: logger ) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index c9404093..f833630b 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -291,9 +291,17 @@ struct HomeView: View { ) return } + // Resolve the app's assigned JIT script the same way a normal JIT run + // does, so the script also runs in hold mode instead of only attaching. + var script: DebugAppCallback? = nil + if ProcessInfo.processInfo.hasTXM, + let preferred = ScriptStore.preferredScript(for: bundleID) { + script = getJsCallback(preferred.data, name: preferred.name) + } + let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) AccessibilityAnnouncer.announce(startingMessage) - BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script) } private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { From 23e8052c7bfc68a35580cd771bed565bf296f3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 10:47:40 +0000 Subject: [PATCH 25/28] Fix experimental keep-alive, wire it into "Other" tab, add Dynamic Island Live Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-alive for any app: - On TXM devices (iOS 26+), keep-alive now falls back to the bundled universal JIT script for apps with no assigned/name-matched script. Previously only known apps got a script callback, so a generic app's JIT brk syscalls went unserviced and the app hung / was killed in the background — the reason keep-alive "didn't work for any app". The fallback is read straight from the app bundle so the current, cancellation-aware script is always used. - Stopping a hold now reliably stops the script loop: the hold and its JIT script share one cancellation token, exposed to JS as should_continue() and honored by send_command(). universal.js checks should_continue() each iteration. On stop we interrupt + detach so the held app keeps running instead of being torn down. The cancellation flag is only read from the script's own thread, so there is no unsafe concurrent access to the (non-thread-safe) debug proxy. Banner from the "Other" tab: - Selecting an app in the "Other" (launch) tab while keep-alive is enabled now holds it in the background and shows the banner, instead of silently launching without a debugger. The launch rows show a green "Hold" action and a footer note in this mode. Dynamic Island / Live Activity: - New StikDebugWidgets widget-extension target hosting a keep-alive Live Activity, with compact/expanded/minimal Dynamic Island presentations and a Lock Screen view. Shared KeepAliveActivityAttributes lives in a StikDebugShared group used by both the app and the extension. - BackgroundAliveManager starts the activity when a hold begins and ends it when the hold stops; stale activities from a previous run are cleared at launch. - Added NSSupportsLiveActivities to the app Info.plist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug.xcodeproj/project.pbxproj | 155 ++++++++++++++++++ StikDebug/App/AppBootstrapper.swift | 3 + StikDebug/Device/JITEnableContext.swift | 16 ++ .../InstalledApps/InstalledAppRows.swift | 17 +- StikDebug/Info.plist | 4 + StikDebug/JSSupport/RunJSView.swift | 29 +++- StikDebug/Scripts/universal.js | 9 +- .../Services/BackgroundAliveManager.swift | 15 +- .../Services/KeepAliveLiveActivity.swift | 114 +++++++++++++ StikDebug/Support/ScriptStore.swift | 37 +++++ StikDebug/Views/HomeView.swift | 31 +++- StikDebug/Views/InstalledAppsListView.swift | 33 +++- .../KeepAliveActivityAttributes.swift | 29 ++++ StikDebugWidgets/Info.plist | 11 ++ .../KeepAliveLiveActivityWidget.swift | 88 ++++++++++ StikDebugWidgets/StikDebugWidgetsBundle.swift | 17 ++ 16 files changed, 579 insertions(+), 29 deletions(-) create mode 100644 StikDebug/Services/KeepAliveLiveActivity.swift create mode 100644 StikDebugShared/KeepAliveActivityAttributes.swift create mode 100644 StikDebugWidgets/Info.plist create mode 100644 StikDebugWidgets/KeepAliveLiveActivityWidget.swift create mode 100644 StikDebugWidgets/StikDebugWidgetsBundle.swift diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 1acfce68..cfbdc128 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; }; + F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,6 +27,13 @@ remoteGlobalIDString = DC6F1D362D94EADD0071B2B6; remoteInfo = StikDebug; }; + F00DCAFE000000000000E00D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F00DCAFE000000000000E001; + remoteInfo = StikDebugWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -35,6 +43,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -47,6 +56,7 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F00DCAFE000000000000E002 /* StikDebugWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = StikDebugWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -57,6 +67,13 @@ ); target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; }; + F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F00DCAFE000000000000E001 /* StikDebugWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -68,6 +85,19 @@ path = StikDebug; sourceTree = ""; }; + F00DCAFE000000000000E006 /* StikDebugWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */, + ); + path = StikDebugWidgets; + sourceTree = ""; + }; + F00DCAFE000000000000E007 /* StikDebugShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = StikDebugShared; + sourceTree = ""; + }; DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = StikDebugTests; @@ -104,6 +134,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F00DCAFE000000000000E004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -111,6 +148,8 @@ isa = PBXGroup; children = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, + F00DCAFE000000000000E007 /* StikDebugShared */, + F00DCAFE000000000000E006 /* StikDebugWidgets */, DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */, DC6F1D552D94EADF0071B2B6 /* StikDebugUITests */, DC6F1D752D94EB620071B2B6 /* Frameworks */, @@ -124,6 +163,7 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */, DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */, DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */, + F00DCAFE000000000000E002 /* StikDebugWidgets.appex */, ); name = Products; sourceTree = ""; @@ -152,9 +192,11 @@ buildRules = ( ); dependencies = ( + F00DCAFE000000000000E00C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, + F00DCAFE000000000000E007 /* StikDebugShared */, ); name = StikDebug; packageProductDependencies = ( @@ -165,6 +207,29 @@ productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */; productType = "com.apple.product-type.application"; }; + F00DCAFE000000000000E001 /* StikDebugWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */; + buildPhases = ( + F00DCAFE000000000000E003 /* Sources */, + F00DCAFE000000000000E004 /* Frameworks */, + F00DCAFE000000000000E005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F00DCAFE000000000000E006 /* StikDebugWidgets */, + F00DCAFE000000000000E007 /* StikDebugShared */, + ); + name = StikDebugWidgets; + packageProductDependencies = ( + ); + productName = StikDebugWidgets; + productReference = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; DC6F1D472D94EADF0071B2B6 /* StikDebugTests */ = { isa = PBXNativeTarget; buildConfigurationList = DC6F1D5F2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebugTests" */; @@ -233,6 +298,9 @@ CreatedOnToolsVersion = 16.2; TestTargetID = DC6F1D362D94EADD0071B2B6; }; + F00DCAFE000000000000E001 = { + CreatedOnToolsVersion = 16.2; + }; }; }; buildConfigurationList = DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */; @@ -255,6 +323,7 @@ projectRoot = ""; targets = ( DC6F1D362D94EADD0071B2B6 /* StikDebug */, + F00DCAFE000000000000E001 /* StikDebugWidgets */, DC6F1D472D94EADF0071B2B6 /* StikDebugTests */, DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */, ); @@ -283,6 +352,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F00DCAFE000000000000E005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -307,6 +383,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F00DCAFE000000000000E003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -320,6 +403,11 @@ target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; targetProxy = DC6F1D532D94EADF0071B2B6 /* PBXContainerItemProxy */; }; + F00DCAFE000000000000E00C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F00DCAFE000000000000E001 /* StikDebugWidgets */; + targetProxy = F00DCAFE000000000000E00D /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -668,6 +756,64 @@ }; name = Release; }; + F00DCAFE000000000000E009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StikDebugWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = StikDebug; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.1.6; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F00DCAFE000000000000E00A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StikDebugWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = StikDebug; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 3.1.6; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -707,6 +853,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F00DCAFE000000000000E009 /* Debug */, + F00DCAFE000000000000E00A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 4ff12d50..6518d46c 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -13,6 +13,9 @@ enum AppBootstrapper { startConfiguredKeepAliveServices() applyDocumentPickerCopyWorkaround() NetworkPathMonitor.shared.start() + // No hold is active at launch, so clear any Live Activity a previous + // (possibly force-quit) run left showing in the Dynamic Island. + KeepAliveLiveActivity.endStaleActivities() } private static func registerDefaultSettings() { diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index c1db201e..2ecbf3c2 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -745,6 +745,22 @@ final class JITEnableContext { script(pid, debugProxy, remoteServer, semaphore) semaphore.wait() emitLog("Keep-alive: JIT script ended (app detached or exited)", logger: logger) + + // If the user stopped the hold (rather than the app detaching on its + // own), detach cleanly so the app keeps running instead of being + // torn down when the debug connection closes. + if cancellation.isCancelled { + var interrupt: UInt8 = 0x03 + _ = debug_proxy_send_raw(debugProxy, &interrupt, 1) + usleep(100_000) + do { + if let response = try sendDebugCommand("D", debugProxy: debugProxy) { + emitLog("Keep-alive detach response: \(response)", logger: logger) + } + } catch { + emitLog(error.localizedDescription, logger: logger) + } + } return } 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/Info.plist b/StikDebug/Info.plist index 60fb1641..9862fb88 100644 --- a/StikDebug/Info.plist +++ b/StikDebug/Info.plist @@ -21,6 +21,10 @@ _stikdebug._tcp _stikdebug._udp + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + UIBackgroundModes audio diff --git a/StikDebug/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 0d5ef1ea..5c776e4b 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,11 +59,11 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable self.context?.exception = JSValue(object: "Command should not be nil.", in: self.context!) return "" } - if self.executionInterrupted { + if self.executionInterrupted || self.cancellation?.isCancelled == true { self.context?.exception = JSValue(object: "Script execution is interrupted by StikDebug.", in: self.context!) return "" } - + return handleJSContextSendDebugCommand(self.context, commandStr, self.debugProxy) ?? "" } @@ -78,7 +84,17 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable let hasTXMFunction: @convention(block) () -> Bool = { return ProcessInfo.processInfo.hasTXM } - + + // Lets a hold script exit cleanly when the user stops the keep-alive + // session. Returns false once interrupted or cancelled; scripts that + // loop forever (e.g. universal.js hold mode) check this each iteration. + // Always true for a normal one-shot JIT run (no cancellation token). + let shouldContinueFunction: @convention(block) () -> Bool = { + if self.executionInterrupted { return false } + if self.cancellation?.isCancelled == true { return false } + return true + } + context = JSContext() context?.setObject(hasTXMFunction, forKeyedSubscript: "hasTXM" as NSString) context?.setObject(getPidFunction, forKeyedSubscript: "get_pid" as NSString) @@ -86,7 +102,8 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable context?.setObject(prepareMemoryRegionFunction, forKeyedSubscript: "prepare_memory_region" as NSString) context?.setObject(takeScreenshotFunction, forKeyedSubscript: "take_screenshot" as NSString) context?.setObject(logFunction, forKeyedSubscript: "log" as NSString) - + context?.setObject(shouldContinueFunction, forKeyedSubscript: "should_continue" as NSString) + context?.evaluateScript(scriptContent) if let semaphore { semaphore.signal() 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 index 3c0ae8f9..9b2c391f 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -51,13 +51,16 @@ final class BackgroundAliveManager: ObservableObject { /// - Parameter script: Optional JIT-script callback to run once (after /// attach, before the hold begins) so the app's assigned script executes /// in hold mode just like it does for a normal JIT run. - func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil) { + /// - Parameter token: Cancellation token for the hold. Pass the same token + /// the `script` callback was built with so that stopping the session also + /// stops the script's loop; defaults to a fresh token when there is no + /// script. + func start(bundleID: String, displayName: String?, script: DebugAppCallback? = nil, token: HoldToken = HoldToken()) { lock.lock() guard activeBundleID == nil else { lock.unlock() return } - let token = HoldToken() activeBundleID = bundleID self.token = token lock.unlock() @@ -69,7 +72,12 @@ final class BackgroundAliveManager: ObservableObject { lock.unlock() let name = displayName ?? bundleID - DispatchQueue.main.async { self.activeAppName = name } + DispatchQueue.main.async { + self.activeAppName = name + // Surface the hold in the Dynamic Island / Lock Screen so it stays + // visible while the user is in the held app. + KeepAliveLiveActivity.start(appName: name, bundleID: bundleID) + } LogManager.shared.addInfoLog("Starting background keep-alive for \(name)") DispatchQueue.global(qos: .userInitiated).async { @@ -98,6 +106,7 @@ final class BackgroundAliveManager: ObservableObject { DispatchQueue.main.async { if stillCurrent { self.activeAppName = nil + KeepAliveLiveActivity.end() } if !succeeded { showAlert( diff --git a/StikDebug/Services/KeepAliveLiveActivity.swift b/StikDebug/Services/KeepAliveLiveActivity.swift new file mode 100644 index 00000000..16b54f78 --- /dev/null +++ b/StikDebug/Services/KeepAliveLiveActivity.swift @@ -0,0 +1,114 @@ +// +// KeepAliveLiveActivity.swift +// StikDebug +// +// App-side controller for the background keep-alive Live Activity. Starts a +// Live Activity when a hold begins so it shows in the Dynamic Island (on +// compatible iPhones) and on the Lock Screen, and ends it when the hold stops. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit +#endif + +enum KeepAliveLiveActivity { + private static let lock = NSLock() + /// Type-erased storage for the current `Activity`, so this enum has no + /// availability requirement of its own and callers don't need to guard. + private static var storage: Any? + + /// Start (or replace) the keep-alive Live Activity for the given app. + static func start(appName: String, bundleID: String) { + #if canImport(ActivityKit) + guard #available(iOS 16.1, *) else { return } + startActivity(appName: appName, bundleID: bundleID) + #endif + } + + /// Update the app name shown by the current Live Activity, if any. + static func update(appName: String) { + #if canImport(ActivityKit) + guard #available(iOS 16.1, *) else { return } + updateActivity(appName: appName) + #endif + } + + /// End the current keep-alive Live Activity, if any. + static func end() { + #if canImport(ActivityKit) + guard #available(iOS 16.1, *) else { return } + endActivity() + #endif + } + + /// End any keep-alive activities left running by a previous launch (for + /// example if the app was force-quit or crashed mid-hold). Call this once at + /// startup, when no hold is active. + static func endStaleActivities() { + #if canImport(ActivityKit) + guard #available(iOS 16.1, *) else { return } + for activity in Activity.activities { + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } + lock.lock() + storage = nil + lock.unlock() + #endif + } + + #if canImport(ActivityKit) + @available(iOS 16.1, *) + private static func startActivity(appName: String, bundleID: String) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + LogManager.shared.addInfoLog("Keep-alive Live Activity not started: Live Activities are disabled in Settings") + return + } + + // Only one hold runs at a time, so make sure any stale activity is gone. + endActivity() + + let attributes = KeepAliveActivityAttributes(appName: appName, bundleID: bundleID) + let state = KeepAliveActivityAttributes.ContentState(appName: appName) + + do { + let activity = try Activity.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: nil) + ) + lock.lock() + storage = activity + lock.unlock() + } catch { + LogManager.shared.addErrorLog("Keep-alive Live Activity failed to start: \(error.localizedDescription)") + } + } + + @available(iOS 16.1, *) + private static func updateActivity(appName: String) { + lock.lock() + let activity = storage as? Activity + lock.unlock() + guard let activity else { return } + + let state = KeepAliveActivityAttributes.ContentState(appName: appName) + Task { + await activity.update(ActivityContent(state: state, staleDate: nil)) + } + } + + @available(iOS 16.1, *) + private static func endActivity() { + lock.lock() + let activity = storage as? Activity + storage = nil + lock.unlock() + guard let activity else { return } + + Task { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + #endif +} diff --git a/StikDebug/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 0e609a3e..3f1b9ead 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -72,6 +72,43 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } + /// The JIT script to run when holding an app alive in the background. + /// + /// On a TXM device (iOS 26+) JIT is serviced by handling `brk #0xf00d` + /// syscalls, so an app that is merely attached — with no script servicing + /// those traps — hangs the moment it requests executable memory. That is + /// why keep-alive "didn't work for any app": only apps whose name/bundle + /// matched an assigned or auto script got a callback. Fall back to the + /// bundled universal script so *any* app has its JIT traps serviced (and, + /// for non-JIT apps, its debug connection actively held) while alive. + /// + /// Returns `nil` on non-TXM devices, where JIT comes from `CS_DEBUGGED` + /// alone and a plain attach is enough to hold the app. + static func keepAliveScript(for bundleID: String, fileManager: FileManager = .default) -> (data: Data, name: String)? { + guard ProcessInfo.processInfo.hasTXM else { + return nil + } + + if let preferred = preferredScript(for: bundleID, fileManager: fileManager) { + return preferred + } + + return bundledScript(resourceName: "universal", fileName: "universal.js") + } + + /// Reads a bundled script straight from the app bundle (not the user's + /// scripts directory). Used for the keep-alive fallback so the current + /// shipped script — which supports `should_continue()` cancellation — is + /// always used, even for users whose documents copy predates it. + private static func bundledScript(resourceName: String, fileName: String) -> (data: Data, name: String)? { + guard let bundleURL = Bundle.main.url(forResource: resourceName, withExtension: "js"), + let data = try? Data(contentsOf: bundleURL) else { + return nil + } + + return (data, fileName) + } + static func favoriteAppName( for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index f833630b..35d3fefb 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -41,7 +41,14 @@ struct HomeView: View { } else { startJITInBackground(bundleID: selectedBundle, displayName: selectedName) } - }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) + }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }, onHoldApp: { selectedBundle, selectedName in + // Selecting an app from the "Other" tab while keep-alive is enabled + // holds it in the background (and shows the banner) instead of just + // launching it — the same behavior as the JIT tab. + bundleID = selectedBundle + Haptics.medium() + startKeepAlive(bundleID: selectedBundle, displayName: selectedName) + }) .overlay(alignment: .top) { if let activeApp = aliveManager.activeAppName { keepAliveBanner(appName: activeApp) @@ -291,17 +298,22 @@ struct HomeView: View { ) return } - // Resolve the app's assigned JIT script the same way a normal JIT run - // does, so the script also runs in hold mode instead of only attaching. + // Share one cancellation token between the hold and the JIT script so + // that stopping the session also stops the script's loop. + let token = HoldToken() + + // Resolve the JIT script to run in hold mode. On TXM devices this falls + // back to the bundled universal script for apps without an assigned or + // name-matched script, so keep-alive services JIT (and actively holds + // the debug connection) for *any* app instead of only known ones. var script: DebugAppCallback? = nil - if ProcessInfo.processInfo.hasTXM, - let preferred = ScriptStore.preferredScript(for: bundleID) { - script = getJsCallback(preferred.data, name: preferred.name) + if let holdScript = ScriptStore.keepAliveScript(for: bundleID) { + script = getJsCallback(holdScript.data, name: holdScript.name, cancellation: token) } let startingMessage = String(format: "Keeping %@ alive in the background".localized, name) AccessibilityAnnouncer.announce(startingMessage) - BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script) + BackgroundAliveManager.shared.start(bundleID: bundleID, displayName: displayName, script: script, token: token) } private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { @@ -325,12 +337,13 @@ struct HomeView: View { .accessibilityLabel(feedback.message) } - private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { + private func getJsCallback(_ script: Data, name: String? = nil, cancellation: HoldToken? = nil) -> DebugAppCallback { return { pid, debugProxyHandle, remoteServerHandle, semaphore in let model = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, remoteServer: remoteServerHandle, - semaphore: semaphore) + semaphore: semaphore, + cancellation: cancellation) DispatchQueue.main.async { scriptRunModel = model 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/StikDebugShared/KeepAliveActivityAttributes.swift b/StikDebugShared/KeepAliveActivityAttributes.swift new file mode 100644 index 00000000..f047efd2 --- /dev/null +++ b/StikDebugShared/KeepAliveActivityAttributes.swift @@ -0,0 +1,29 @@ +// +// KeepAliveActivityAttributes.swift +// StikDebug +// +// Shared between the app (which starts/updates/ends the activity) and the +// widget extension (which renders it in the Dynamic Island / on the Lock +// Screen). Keep this file in the StikDebugShared group so both targets compile +// the same type. +// + +import Foundation + +#if canImport(ActivityKit) +import ActivityKit + +@available(iOS 16.1, *) +struct KeepAliveActivityAttributes: ActivityAttributes { + /// The parts of the activity that change while it is live. + struct ContentState: Codable, Hashable { + /// The display name of the app currently being held alive. + var appName: String + } + + /// The app being held alive when the activity started. + var appName: String + /// The bundle identifier of that app. + var bundleID: String +} +#endif diff --git a/StikDebugWidgets/Info.plist b/StikDebugWidgets/Info.plist new file mode 100644 index 00000000..0f118fb7 --- /dev/null +++ b/StikDebugWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift new file mode 100644 index 00000000..ab950209 --- /dev/null +++ b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift @@ -0,0 +1,88 @@ +// +// KeepAliveLiveActivityWidget.swift +// StikDebugWidgets +// +// Live Activity presentation for the background keep-alive session. Shows the +// held app in the Dynamic Island (compact, expanded, and minimal) and on the +// Lock Screen. +// + +import WidgetKit +import SwiftUI +import ActivityKit + +@available(iOS 16.1, *) +struct KeepAliveLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: KeepAliveActivityAttributes.self) { context in + // Lock Screen / banner presentation. + KeepAliveLockScreenView(appName: context.state.appName) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(.green) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "bolt.circle.fill") + .font(.title2) + .foregroundStyle(.green) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.trailing) { + Text("JIT") + .font(.caption.weight(.bold)) + .foregroundStyle(.green) + .padding(.trailing, 4) + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.appName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + DynamicIslandExpandedRegion(.bottom) { + Text("Held alive in the background") + .font(.caption) + .foregroundStyle(.secondary) + } + } compactLeading: { + Image(systemName: "bolt.circle.fill") + .foregroundStyle(.green) + } compactTrailing: { + Text(context.state.appName) + .font(.caption2) + .lineLimit(1) + .frame(maxWidth: 64) + } minimal: { + Image(systemName: "bolt.circle.fill") + .foregroundStyle(.green) + } + .keylineTint(.green) + } + } +} + +@available(iOS 16.1, *) +private struct KeepAliveLockScreenView: View { + let appName: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "bolt.circle.fill") + .font(.title) + .foregroundStyle(.green) + + VStack(alignment: .leading, spacing: 2) { + Text("Keeping \(appName) alive") + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text("Held in the background") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding() + } +} diff --git a/StikDebugWidgets/StikDebugWidgetsBundle.swift b/StikDebugWidgets/StikDebugWidgetsBundle.swift new file mode 100644 index 00000000..2226e4b5 --- /dev/null +++ b/StikDebugWidgets/StikDebugWidgetsBundle.swift @@ -0,0 +1,17 @@ +// +// StikDebugWidgetsBundle.swift +// StikDebugWidgets +// +// Widget extension bundle. Hosts the keep-alive Live Activity so it can be +// rendered in the Dynamic Island and on the Lock Screen. +// + +import WidgetKit +import SwiftUI + +@main +struct StikDebugWidgetsBundle: WidgetBundle { + var body: some Widget { + KeepAliveLiveActivityWidget() + } +} From e4f02100ee4637db718234d3dbebbb0974ab9215 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 10:54:26 +0000 Subject: [PATCH 26/28] Remove widget extension, keep the keep-alive banner in-app only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the StikDebugWidgets extension target, the shared ActivityAttributes, and the KeepAliveLiveActivity controller — no Dynamic Island / Lock Screen presentation. The banner stays as the existing in-app overlay in HomeView, now also shown when holding an app from the "Other" tab. project.pbxproj, Info.plist, and AppBootstrapper.swift are back to their pre-widget state. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug.xcodeproj/project.pbxproj | 155 ------------------ StikDebug/App/AppBootstrapper.swift | 3 - StikDebug/Info.plist | 4 - .../Services/BackgroundAliveManager.swift | 8 +- .../Services/KeepAliveLiveActivity.swift | 114 ------------- .../KeepAliveActivityAttributes.swift | 29 ---- StikDebugWidgets/Info.plist | 11 -- .../KeepAliveLiveActivityWidget.swift | 88 ---------- StikDebugWidgets/StikDebugWidgetsBundle.swift | 17 -- 9 files changed, 1 insertion(+), 428 deletions(-) delete mode 100644 StikDebug/Services/KeepAliveLiveActivity.swift delete mode 100644 StikDebugShared/KeepAliveActivityAttributes.swift delete mode 100644 StikDebugWidgets/Info.plist delete mode 100644 StikDebugWidgets/KeepAliveLiveActivityWidget.swift delete mode 100644 StikDebugWidgets/StikDebugWidgetsBundle.swift diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index cfbdc128..1acfce68 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; 68D569C02E1B415700A5BA36 /* LanguageSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BF2E1B415700A5BA36 /* LanguageSupport */; }; - F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -27,13 +26,6 @@ remoteGlobalIDString = DC6F1D362D94EADD0071B2B6; remoteInfo = StikDebug; }; - F00DCAFE000000000000E00D /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = F00DCAFE000000000000E001; - remoteInfo = StikDebugWidgets; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -43,7 +35,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - F00DCAFE000000000000E00B /* StikDebugWidgets.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -56,7 +47,6 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F00DCAFE000000000000E002 /* StikDebugWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = StikDebugWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -67,13 +57,6 @@ ); target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; }; - F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = F00DCAFE000000000000E001 /* StikDebugWidgets */; - }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -85,19 +68,6 @@ path = StikDebug; sourceTree = ""; }; - F00DCAFE000000000000E006 /* StikDebugWidgets */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - F00DCAFE000000000000E00E /* Exceptions for "StikDebugWidgets" folder in "StikDebugWidgets" target */, - ); - path = StikDebugWidgets; - sourceTree = ""; - }; - F00DCAFE000000000000E007 /* StikDebugShared */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = StikDebugShared; - sourceTree = ""; - }; DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = StikDebugTests; @@ -134,13 +104,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E004 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -148,8 +111,6 @@ isa = PBXGroup; children = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E007 /* StikDebugShared */, - F00DCAFE000000000000E006 /* StikDebugWidgets */, DC6F1D4B2D94EADF0071B2B6 /* StikDebugTests */, DC6F1D552D94EADF0071B2B6 /* StikDebugUITests */, DC6F1D752D94EB620071B2B6 /* Frameworks */, @@ -163,7 +124,6 @@ DC6F1D372D94EADD0071B2B6 /* StikDebug.app */, DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */, DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */, - F00DCAFE000000000000E002 /* StikDebugWidgets.appex */, ); name = Products; sourceTree = ""; @@ -192,11 +152,9 @@ buildRules = ( ); dependencies = ( - F00DCAFE000000000000E00C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DC6F1D392D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E007 /* StikDebugShared */, ); name = StikDebug; packageProductDependencies = ( @@ -207,29 +165,6 @@ productReference = DC6F1D372D94EADD0071B2B6 /* StikDebug.app */; productType = "com.apple.product-type.application"; }; - F00DCAFE000000000000E001 /* StikDebugWidgets */ = { - isa = PBXNativeTarget; - buildConfigurationList = F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */; - buildPhases = ( - F00DCAFE000000000000E003 /* Sources */, - F00DCAFE000000000000E004 /* Frameworks */, - F00DCAFE000000000000E005 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - F00DCAFE000000000000E006 /* StikDebugWidgets */, - F00DCAFE000000000000E007 /* StikDebugShared */, - ); - name = StikDebugWidgets; - packageProductDependencies = ( - ); - productName = StikDebugWidgets; - productReference = F00DCAFE000000000000E002 /* StikDebugWidgets.appex */; - productType = "com.apple.product-type.app-extension"; - }; DC6F1D472D94EADF0071B2B6 /* StikDebugTests */ = { isa = PBXNativeTarget; buildConfigurationList = DC6F1D5F2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebugTests" */; @@ -298,9 +233,6 @@ CreatedOnToolsVersion = 16.2; TestTargetID = DC6F1D362D94EADD0071B2B6; }; - F00DCAFE000000000000E001 = { - CreatedOnToolsVersion = 16.2; - }; }; }; buildConfigurationList = DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */; @@ -323,7 +255,6 @@ projectRoot = ""; targets = ( DC6F1D362D94EADD0071B2B6 /* StikDebug */, - F00DCAFE000000000000E001 /* StikDebugWidgets */, DC6F1D472D94EADF0071B2B6 /* StikDebugTests */, DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */, ); @@ -352,13 +283,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -383,13 +307,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F00DCAFE000000000000E003 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -403,11 +320,6 @@ target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; targetProxy = DC6F1D532D94EADF0071B2B6 /* PBXContainerItemProxy */; }; - F00DCAFE000000000000E00C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = F00DCAFE000000000000E001 /* StikDebugWidgets */; - targetProxy = F00DCAFE000000000000E00D /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -756,64 +668,6 @@ }; name = Release; }; - F00DCAFE000000000000E009 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = StikDebugWidgets/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = StikDebug; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.4; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 3.1.6; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - F00DCAFE000000000000E00A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = StikDebugWidgets/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = StikDebug; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.4; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 3.1.6; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.widgets; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -853,15 +707,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F00DCAFE000000000000E008 /* Build configuration list for PBXNativeTarget "StikDebugWidgets" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F00DCAFE000000000000E009 /* Debug */, - F00DCAFE000000000000E00A /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 6518d46c..4ff12d50 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -13,9 +13,6 @@ enum AppBootstrapper { startConfiguredKeepAliveServices() applyDocumentPickerCopyWorkaround() NetworkPathMonitor.shared.start() - // No hold is active at launch, so clear any Live Activity a previous - // (possibly force-quit) run left showing in the Dynamic Island. - KeepAliveLiveActivity.endStaleActivities() } private static func registerDefaultSettings() { diff --git a/StikDebug/Info.plist b/StikDebug/Info.plist index 9862fb88..60fb1641 100644 --- a/StikDebug/Info.plist +++ b/StikDebug/Info.plist @@ -21,10 +21,6 @@ _stikdebug._tcp _stikdebug._udp - NSSupportsLiveActivities - - NSSupportsLiveActivitiesFrequentUpdates - UIBackgroundModes audio diff --git a/StikDebug/Services/BackgroundAliveManager.swift b/StikDebug/Services/BackgroundAliveManager.swift index 9b2c391f..5236301b 100644 --- a/StikDebug/Services/BackgroundAliveManager.swift +++ b/StikDebug/Services/BackgroundAliveManager.swift @@ -72,12 +72,7 @@ final class BackgroundAliveManager: ObservableObject { lock.unlock() let name = displayName ?? bundleID - DispatchQueue.main.async { - self.activeAppName = name - // Surface the hold in the Dynamic Island / Lock Screen so it stays - // visible while the user is in the held app. - KeepAliveLiveActivity.start(appName: name, bundleID: bundleID) - } + DispatchQueue.main.async { self.activeAppName = name } LogManager.shared.addInfoLog("Starting background keep-alive for \(name)") DispatchQueue.global(qos: .userInitiated).async { @@ -106,7 +101,6 @@ final class BackgroundAliveManager: ObservableObject { DispatchQueue.main.async { if stillCurrent { self.activeAppName = nil - KeepAliveLiveActivity.end() } if !succeeded { showAlert( diff --git a/StikDebug/Services/KeepAliveLiveActivity.swift b/StikDebug/Services/KeepAliveLiveActivity.swift deleted file mode 100644 index 16b54f78..00000000 --- a/StikDebug/Services/KeepAliveLiveActivity.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// KeepAliveLiveActivity.swift -// StikDebug -// -// App-side controller for the background keep-alive Live Activity. Starts a -// Live Activity when a hold begins so it shows in the Dynamic Island (on -// compatible iPhones) and on the Lock Screen, and ends it when the hold stops. -// - -import Foundation - -#if canImport(ActivityKit) -import ActivityKit -#endif - -enum KeepAliveLiveActivity { - private static let lock = NSLock() - /// Type-erased storage for the current `Activity`, so this enum has no - /// availability requirement of its own and callers don't need to guard. - private static var storage: Any? - - /// Start (or replace) the keep-alive Live Activity for the given app. - static func start(appName: String, bundleID: String) { - #if canImport(ActivityKit) - guard #available(iOS 16.1, *) else { return } - startActivity(appName: appName, bundleID: bundleID) - #endif - } - - /// Update the app name shown by the current Live Activity, if any. - static func update(appName: String) { - #if canImport(ActivityKit) - guard #available(iOS 16.1, *) else { return } - updateActivity(appName: appName) - #endif - } - - /// End the current keep-alive Live Activity, if any. - static func end() { - #if canImport(ActivityKit) - guard #available(iOS 16.1, *) else { return } - endActivity() - #endif - } - - /// End any keep-alive activities left running by a previous launch (for - /// example if the app was force-quit or crashed mid-hold). Call this once at - /// startup, when no hold is active. - static func endStaleActivities() { - #if canImport(ActivityKit) - guard #available(iOS 16.1, *) else { return } - for activity in Activity.activities { - Task { await activity.end(nil, dismissalPolicy: .immediate) } - } - lock.lock() - storage = nil - lock.unlock() - #endif - } - - #if canImport(ActivityKit) - @available(iOS 16.1, *) - private static func startActivity(appName: String, bundleID: String) { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - LogManager.shared.addInfoLog("Keep-alive Live Activity not started: Live Activities are disabled in Settings") - return - } - - // Only one hold runs at a time, so make sure any stale activity is gone. - endActivity() - - let attributes = KeepAliveActivityAttributes(appName: appName, bundleID: bundleID) - let state = KeepAliveActivityAttributes.ContentState(appName: appName) - - do { - let activity = try Activity.request( - attributes: attributes, - content: ActivityContent(state: state, staleDate: nil) - ) - lock.lock() - storage = activity - lock.unlock() - } catch { - LogManager.shared.addErrorLog("Keep-alive Live Activity failed to start: \(error.localizedDescription)") - } - } - - @available(iOS 16.1, *) - private static func updateActivity(appName: String) { - lock.lock() - let activity = storage as? Activity - lock.unlock() - guard let activity else { return } - - let state = KeepAliveActivityAttributes.ContentState(appName: appName) - Task { - await activity.update(ActivityContent(state: state, staleDate: nil)) - } - } - - @available(iOS 16.1, *) - private static func endActivity() { - lock.lock() - let activity = storage as? Activity - storage = nil - lock.unlock() - guard let activity else { return } - - Task { - await activity.end(nil, dismissalPolicy: .immediate) - } - } - #endif -} diff --git a/StikDebugShared/KeepAliveActivityAttributes.swift b/StikDebugShared/KeepAliveActivityAttributes.swift deleted file mode 100644 index f047efd2..00000000 --- a/StikDebugShared/KeepAliveActivityAttributes.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// KeepAliveActivityAttributes.swift -// StikDebug -// -// Shared between the app (which starts/updates/ends the activity) and the -// widget extension (which renders it in the Dynamic Island / on the Lock -// Screen). Keep this file in the StikDebugShared group so both targets compile -// the same type. -// - -import Foundation - -#if canImport(ActivityKit) -import ActivityKit - -@available(iOS 16.1, *) -struct KeepAliveActivityAttributes: ActivityAttributes { - /// The parts of the activity that change while it is live. - struct ContentState: Codable, Hashable { - /// The display name of the app currently being held alive. - var appName: String - } - - /// The app being held alive when the activity started. - var appName: String - /// The bundle identifier of that app. - var bundleID: String -} -#endif diff --git a/StikDebugWidgets/Info.plist b/StikDebugWidgets/Info.plist deleted file mode 100644 index 0f118fb7..00000000 --- a/StikDebugWidgets/Info.plist +++ /dev/null @@ -1,11 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift b/StikDebugWidgets/KeepAliveLiveActivityWidget.swift deleted file mode 100644 index ab950209..00000000 --- a/StikDebugWidgets/KeepAliveLiveActivityWidget.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// KeepAliveLiveActivityWidget.swift -// StikDebugWidgets -// -// Live Activity presentation for the background keep-alive session. Shows the -// held app in the Dynamic Island (compact, expanded, and minimal) and on the -// Lock Screen. -// - -import WidgetKit -import SwiftUI -import ActivityKit - -@available(iOS 16.1, *) -struct KeepAliveLiveActivityWidget: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: KeepAliveActivityAttributes.self) { context in - // Lock Screen / banner presentation. - KeepAliveLockScreenView(appName: context.state.appName) - .activityBackgroundTint(Color.black.opacity(0.55)) - .activitySystemActionForegroundColor(.green) - } dynamicIsland: { context in - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Image(systemName: "bolt.circle.fill") - .font(.title2) - .foregroundStyle(.green) - .padding(.leading, 4) - } - DynamicIslandExpandedRegion(.trailing) { - Text("JIT") - .font(.caption.weight(.bold)) - .foregroundStyle(.green) - .padding(.trailing, 4) - } - DynamicIslandExpandedRegion(.center) { - Text(context.state.appName) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - DynamicIslandExpandedRegion(.bottom) { - Text("Held alive in the background") - .font(.caption) - .foregroundStyle(.secondary) - } - } compactLeading: { - Image(systemName: "bolt.circle.fill") - .foregroundStyle(.green) - } compactTrailing: { - Text(context.state.appName) - .font(.caption2) - .lineLimit(1) - .frame(maxWidth: 64) - } minimal: { - Image(systemName: "bolt.circle.fill") - .foregroundStyle(.green) - } - .keylineTint(.green) - } - } -} - -@available(iOS 16.1, *) -private struct KeepAliveLockScreenView: View { - let appName: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "bolt.circle.fill") - .font(.title) - .foregroundStyle(.green) - - VStack(alignment: .leading, spacing: 2) { - Text("Keeping \(appName) alive") - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.7) - Text("Held in the background") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - } - .padding() - } -} diff --git a/StikDebugWidgets/StikDebugWidgetsBundle.swift b/StikDebugWidgets/StikDebugWidgetsBundle.swift deleted file mode 100644 index 2226e4b5..00000000 --- a/StikDebugWidgets/StikDebugWidgetsBundle.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// StikDebugWidgetsBundle.swift -// StikDebugWidgets -// -// Widget extension bundle. Hosts the keep-alive Live Activity so it can be -// rendered in the Dynamic Island and on the Lock Screen. -// - -import WidgetKit -import SwiftUI - -@main -struct StikDebugWidgetsBundle: WidgetBundle { - var body: some Widget { - KeepAliveLiveActivityWidget() - } -} From 67794a0557fb96e83963654920e343d9789f287f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:04:37 +0000 Subject: [PATCH 27/28] Fix keep-alive crash on non-JIT apps (e.g. Roblox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-alive ran the universal JIT breakpoint-handler script against every app on TXM devices. That script is meant for apps that use the brk #0xf00d JIT protocol (emulators). Against a large, multi-threaded, non-JIT app like Roblox it chases every signal the app raises — logging verbosely each iteration — which: - grows RunJSViewModel.logs (a @Published array rendered in a SwiftUI List) without bound until StikDebug runs out of memory and crashes, and - mishandles the app's many threads (single-thread vCont), wedging it. Two fixes: - keepAliveScript now returns a script only for user-assigned or name-matched (known JIT) apps. Everything else is held with a plain debugger attach, which keeps the app alive in the background without interfering with its threads or signals. This restores the proven plain-hold path for apps like Roblox. - appendLog caps the log buffer (with amortized trimming) so a long-running or spinning hold script can never exhaust memory. The user can still stop such a hold via should_continue(). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug/JSSupport/RunJSView.swift | 24 +++++++++++++++---- StikDebug/Support/ScriptStore.swift | 37 +++++++++-------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/StikDebug/JSSupport/RunJSView.swift b/StikDebug/JSSupport/RunJSView.swift index 5c776e4b..df98a489 100644 --- a/StikDebug/JSSupport/RunJSView.swift +++ b/StikDebug/JSSupport/RunJSView.swift @@ -69,7 +69,7 @@ final class RunJSViewModel: ObservableObject, Identifiable, @unchecked Sendable let logFunction: @convention(block) (String) -> Void = { logStr in DispatchQueue.main.async { - self.logs.append(logStr) + self.appendLog(logStr) } } @@ -111,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/Support/ScriptStore.swift b/StikDebug/Support/ScriptStore.swift index 3f1b9ead..0a8fd93c 100644 --- a/StikDebug/Support/ScriptStore.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -72,15 +72,17 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } - /// The JIT script to run when holding an app alive in the background. + /// The JIT script to run while holding an app alive in the background, or + /// `nil` to hold the app with a plain debugger attach. /// - /// On a TXM device (iOS 26+) JIT is serviced by handling `brk #0xf00d` - /// syscalls, so an app that is merely attached — with no script servicing - /// those traps — hangs the moment it requests executable memory. That is - /// why keep-alive "didn't work for any app": only apps whose name/bundle - /// matched an assigned or auto script got a callback. Fall back to the - /// bundled universal script so *any* app has its JIT traps serviced (and, - /// for non-JIT apps, its debug connection actively held) while alive. + /// 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. @@ -89,24 +91,7 @@ enum ScriptStore { return nil } - if let preferred = preferredScript(for: bundleID, fileManager: fileManager) { - return preferred - } - - return bundledScript(resourceName: "universal", fileName: "universal.js") - } - - /// Reads a bundled script straight from the app bundle (not the user's - /// scripts directory). Used for the keep-alive fallback so the current - /// shipped script — which supports `should_continue()` cancellation — is - /// always used, even for users whose documents copy predates it. - private static func bundledScript(resourceName: String, fileName: String) -> (data: Data, name: String)? { - guard let bundleURL = Bundle.main.url(forResource: resourceName, withExtension: "js"), - let data = try? Data(contentsOf: bundleURL) else { - return nil - } - - return (data, fileName) + return preferredScript(for: bundleID, fileManager: fileManager) } static func favoriteAppName( From 04712e26a691d0510d9a2193539dac244104ac11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:33:55 +0000 Subject: [PATCH 28/28] Keep held apps alive by actively servicing the debug connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-script hold sent a single raw "continue" and then only polled a flag — it never read the debug proxy's stop replies. A debugged process stops on every signal/exception, so on the first stop the app stayed halted, couldn't service its run loop, and iOS's watchdog killed it. For a large app like Roblox this happened almost immediately, which is why keep-alive "ended instantly and the app crashed" (and why the plain hold never really worked for any app). Replace the fire-and-forget continue+poll with an active continue-service loop: after attaching, continue on every stop and forward real signals (C) so the app behaves exactly as if it were not being debugged, swallowing only the debugger artifacts (attach SIGSTOP, SIGTRAP traps) with a plain continue. The loop ends cleanly when the app exits (W/X reply), when the connection drops, or when the user stops the hold (then interrupt + detach so the app keeps running). This uses the same blocking send_command("c") mechanism the JIT script already relies on through idle periods, so it is single-threaded and safe (no concurrent access to the non-thread-safe debug proxy). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpCxAimrBo9RA8v6BxSEXT --- StikDebug/Device/JITEnableContext.swift | 71 +++++++++++++++++++------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/StikDebug/Device/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift index 2ecbf3c2..b654e577 100644 --- a/StikDebug/Device/JITEnableContext.swift +++ b/StikDebug/Device/JITEnableContext.swift @@ -764,24 +764,50 @@ final class JITEnableContext { return } - // No script (older devices where JIT is enabled simply by CS_DEBUGGED): - // attach to set CS_DEBUGGED, then continue so the app runs. Sent raw and - // not awaited: a debugger-owned, running process is not stopped by iOS - // when backgrounded, so there are no stop replies to drain — we just hold - // the connection open until the user stops it. + // 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))" - let attachResponse = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" - emitLog("Keep-alive attach response: \(attachResponse)", logger: logger) - sendContinue(debugProxy) + 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) - // Polling a flag (rather than blocking on a socket read) keeps this - // single-threaded and cleanly cancellable. + // 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 { - Thread.sleep(forTimeInterval: 0.5) + // 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 + } } - // Interrupt, then detach, so the app keeps running on its own afterward. + // 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) @@ -794,10 +820,23 @@ final class JITEnableContext { } } - private func sendContinue(_ debugProxy: OpaquePointer) { - // "$c#63" — RSP continue-all-threads packet (valid in no-ack mode). - var packet: [UInt8] = [0x24, 0x63, 0x23, 0x36, 0x33] - _ = debug_proxy_send_raw(debugProxy, &packet, UInt(packet.count)) + /// 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) {