diff --git a/Config/App-Info.plist b/Config/App-Info.plist index db0e528..d08ab89 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -38,7 +38,7 @@ NSLocationAlwaysAndWhenInUseUsageDescription FuelBoard uses Always location to alert you when you approach the cheapest station nearby. NSLocalNetworkUsageDescription - FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices. + FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development. NSSupportsLiveActivities UIBackgroundModes diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index e0dd781..afe5fae 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -56,31 +56,9 @@ struct OnboardingView: View { .onChange(of: prompter.notificationsGranted) { _, granted in if granted, page == 2 { page = 3 } } - .onChange(of: prompter.dataGranted) { _, granted in - if granted, page == 3 { - // The probe result lands while the Local Network prompt is - // still dismissing, so without a beat the green "Connected" - // tick is skipped and the page jumps straight to Done — - // unlike the Location/Notifications pages where the granted - // tick is visible. Hold the tick on screen, then advance. - Task { @MainActor in - try? await Task.sleep(nanoseconds: 800_000_000) - if page == 3 { page = 4 } - } - } - } - // Replay path (test button / returning user): the Local Network - // permission is already decided, so probe silently up front. The - // probe fires no prompt once the permission is granted or denied — - // only the FIRST attempt (undetermined) shows the system alert, and - // that only happens on first run. By the time the data page appears - // its status is known, so it shows the green tick (or denied text) - // immediately, matching the Location and Notifications pages. - .onAppear { - if FuelStore.loadHasCompletedOnboarding() { - prompter.preflightDataAccess() - } - } + // No data-permission step: prices download from the internet (GitHub + // mirror) with no permission at all — Local Network was removed from + // the app when the relay left the consumer chain. .background( LinearGradient( colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)], @@ -207,47 +185,17 @@ struct OnboardingView: View { Text("Prices, ready when you are") .font(.largeTitle.bold()) - Text("FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.") + Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) .padding(.top, 12) - dataStatusLabel - .padding(.top, 24) - Spacer() } } - @ViewBuilder - private var dataStatusLabel: some View { - if prompter.dataLoading { - VStack(spacing: 10) { - ProgressView() - Text("Waiting for network permission…") - .font(.footnote) - .foregroundStyle(.secondary) - } - } else if prompter.dataGranted { - // Same confirmation styling as the Location/Notifications pages. - Label("Connected", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.subheadline) - } else if prompter.dataDenied { - Text("Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } else { - Text("The system prompt will appear next.") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - private var donePage: some View { VStack(spacing: 0) { Spacer() @@ -309,18 +257,8 @@ struct OnboardingView: View { } } case 3: - primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") { - if prompter.dataGranted || prompter.dataDenied { - // Prompt answered (allowed or denied) — move on to the - // final slide. Never dead-end on an Open Settings button. - page = 4 - } else if !prompter.dataLoading { - // Fires the Local Network prompt + relay probe here, - // after the user has read the page and tapped Continue. - prompter.requestDataAccess() - } - } - .disabled(prompter.dataLoading) + // No permission on this page — prices need none. Straight on. + primaryButton("Continue") { page = 4 } default: primaryButton("Start Using FuelBoard") { finish() } } @@ -390,13 +328,6 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur @Published private(set) var locationDenied = false @Published private(set) var notificationsGranted = false @Published private(set) var notificationsDenied = false - @Published private(set) var dataGranted = false - @Published private(set) var dataDenied = false - @Published private(set) var dataLoading = false - - /// Held while the Local Network permission prompt is pending — keeps the - /// bare TCP connect alive until the user answers (nil after resolve). - private var dataConnection: NWConnection? private let manager = CLLocationManager() @@ -438,125 +369,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur } } - /// Triggers the Local Network permission prompt WITHOUT loading any data. - /// iOS shows the prompt on the first attempt to reach a local-network - /// address, so a bare TCP connect to the relay host:port is enough — no - /// HTTP request, no payload, nothing to wait on. Once the user answers - /// the prompt the connect either succeeds (granted) or fails (denied); - /// either way onboarding proceeds. The full dataset download happens - /// AFTER onboarding completes (ContentView's forced refresh). - func requestDataAccess() { - guard !dataLoading else { return } - dataLoading = true - dataDenied = false - let baseURL = RelayFuelProvider().baseURL - guard let host = baseURL.host, - let port = baseURL.port else { - dataLoading = false - dataDenied = true - return - } - let connection = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: UInt16(port))!, - using: .tcp - ) - dataConnection = connection - connection.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - switch state { - case .ready: - // Connect succeeded — local network allowed. - self.dataConnection = nil - self.dataLoading = false - self.dataGranted = true - self.dataDenied = false - case .failed, .cancelled: - // Connect failed (denied, or relay unreachable). Either - // way we do NOT dead-end: onboarding continues. - self.dataConnection = nil - self.dataLoading = false - self.dataGranted = false - self.dataDenied = true - default: - break // .preparing / .waiting — prompt pending, hold on - } - } - } - connection.start(queue: .main) - // Safety timeout: if the prompt is ignored or the connect stalls, drop - // back to the enabled Continue button instead of an endless spinner. - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 12_000_000_000) - guard let self, self.dataLoading else { return } - self.dataLoading = false - self.dataGranted = false - self.dataDenied = true - self.dataConnection = nil - connection.cancel() - } - } - - /// Replay pre-flight: determines the Local Network status WITHOUT any - /// user-visible check. Once the permission is decided (granted or denied) - /// a probe resolves silently — granted connects in milliseconds, denied - /// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`. - /// Only the FIRST-ever attempt (undetermined) shows the system alert; in - /// that case we cancel and leave the state unknown so the data page's - /// Continue triggers the prompt in context. First run never calls this. - func preflightDataAccess() { - guard !dataLoading, !dataGranted, !dataDenied else { return } - dataLoading = true - let baseURL = RelayFuelProvider().baseURL - guard let host = baseURL.host, - let port = baseURL.port else { - dataLoading = false - return - } - let connection = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: UInt16(port))!, - using: .tcp - ) - dataConnection = connection - connection.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - switch state { - case .ready: - self.dataConnection = nil - self.dataLoading = false - self.dataGranted = true - self.dataDenied = false - case .waiting: - if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { - // Denied in Settings — resolved silently, no prompt. - self.dataConnection = nil - self.dataLoading = false - self.dataGranted = false - self.dataDenied = true - } else { - // Prompt pending (permission undetermined — only - // possible after a reinstall, where the onboarding - // flag persisted but the permission reset). Cancel so - // page 4's Continue fires the prompt in context. - self.dataConnection = nil - self.dataLoading = false - connection.cancel() - } - case .failed, .cancelled: - self.dataConnection = nil - self.dataLoading = false - self.dataGranted = false - self.dataDenied = true - default: - break // .preparing — probe starting, hold on - } - } - } - connection.start(queue: .main) - } + /// Replay pre-flight: NO data permission exists anymore — prices download + /// from the internet with no prompt, so there is nothing to preflight. + /// (The LAN relay left the consumer chain; Local Network is gone.) nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { Task { @MainActor in diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index babe622..0379d7b 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -60,6 +60,9 @@ struct SettingsView: View { /// Hidden developer flag — the Debug section only appears when on. Toggled /// by tapping the About → Version row five times (NOT a user-facing switch). @State private var debugMode: Bool = FuelStore.loadDebugMode() + /// Dev-only: re-inserts the LAN relay into the live chain (off by default — + /// consumers must never attempt local-network access). + @State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled() @State private var versionTapCount = 0 @State private var lastVersionTap = Date.distantPast /// Widget diagnostics: the extension's last makeEntry beacon PER intent @@ -129,6 +132,10 @@ struct SettingsView: View { if debugMode { Section { + Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled) + .onChange(of: relayFallbackEnabled) { _, enabled in + FuelStore.saveRelayFallbackEnabled(enabled) + } Button { sendTestAlert() } label: { diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index 2a2d62f..c737edc 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -113,10 +113,7 @@ "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab." = "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab."; "Notifications were denied — you can still use FuelBoard, just without alert banners." = "Notifications were denied — you can still use FuelBoard, just without alert banners."; "Prices, ready when you are" = "Prices, ready when you are"; -"FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download." = "FuelBoard downloads the latest prices daily"; -"Waiting for network permission…" = "Waiting for network permission…"; -"Connected" = "Connected"; -"Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard." = "Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard."; +"FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed." = "Prices download from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed."; "You're all set" = "You're all set"; "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby." = "Find the cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby."; "Continue" = "Continue"; @@ -124,7 +121,6 @@ "Allow Location Access" = "Allow Location Access"; "Continue without alerts" = "Continue without alerts"; "Allow Notifications" = "Allow Notifications"; -"Waiting…" = "Waiting…"; "Start Using FuelBoard" = "Start Using FuelBoard"; /* Station map */ diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift index 630997a..1614bb9 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/LiveChainTests.swift @@ -1,12 +1,16 @@ import XCTest @testable import FuelBoardShared -/// P0 live chain: GitHub mirror → LAN relay → bundled dump. +/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] → +/// (app: bundled dump in ContentView's catch, widget: cache → placeholder). +/// The relay is NOT part of the consumer chain — it only joins behind the +/// hidden dev flag `fuelboard.relayFallback`. final class LiveChainTests: XCTestCase { override func setUp() { super.setUp() FuelBeacon.isEnabled = false // chain tests must not fire real pings + FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default } override func tearDown() { @@ -33,9 +37,9 @@ final class LiveChainTests: XCTestCase { } } - private func fixtureStation(_ id: String = "s1") -> FuelStation { + private func fixtureStation(_ id: String = "s1", lat: Double = 53.7, lng: Double = -1.8) -> FuelStation { FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St", - postcode: "SW1A 1AA", lat: 53.7, lng: -1.8, + postcode: "SW1A 1AA", lat: lat, lng: lng, prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil) } @@ -54,7 +58,21 @@ final class LiveChainTests: XCTestCase { XCTAssertEqual(LiveChainProvider.lastSource, "github") } - func testFullPathFallsBackToRelay() async throws { + func testFullPathRelaySkippedWhenDisabled() async { + let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) + let relay = StubProvider(result: [fixtureStation("relay")]) + let chain = LiveChainProvider(mirror: mirror, relay: relay) + + do { + _ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) + XCTFail("chain must throw when the mirror fails and the relay flag is off") + } catch { + XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") + } + } + + func testFullPathRelayFallbackWhenDevFlagOn() async throws { + FuelStore.saveRelayFallbackEnabled(true) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) @@ -62,35 +80,77 @@ final class LiveChainTests: XCTestCase { let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) XCTAssertEqual(stations.first?.id, "relay") - XCTAssertEqual(relay.callCount, 1, "relay must serve when the mirror is down") + XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves") XCTAssertEqual(LiveChainProvider.lastSource, "relay") } - // MARK: Focused path (alert checks): relay first — never pull the full - // ~2.8 MB dump over mobile data for a background alert when the relay is up. + // MARK: Focused path (alert checks + widget): GitHub first too - func testFocusedPathPrefersRelay() async throws { + func testFocusedPathPrefersMirror() async throws { let mirror = StubProvider(result: [fixtureStation("mirror")]) let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) - _ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) + let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) - XCTAssertEqual(relay.callCount, 1, "focused alert fetch must prefer the light relay call") - XCTAssertEqual(mirror.callCount, 0, "mirror full dump must NOT be pulled when the relay is up") - XCTAssertEqual(LiveChainProvider.lastSource, "relay") + XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN") + XCTAssertEqual(relay.callCount, 0) + XCTAssertEqual(LiveChainProvider.lastSource, "github") } - func testFocusedPathFallsBackToMirror() async throws { - let mirror = StubProvider(result: [fixtureStation("mirror")]) - let relay = StubProvider(error: FuelProviderError.relayUnavailable) + func testFocusedPathRelaySkippedWhenDisabled() async { + let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) + let relay = StubProvider(result: [fixtureStation("relay")]) + let chain = LiveChainProvider(mirror: mirror, relay: relay) + + do { + _ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) + XCTFail("focused path must throw when the mirror fails and the relay flag is off") + } catch { + XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") + } + } + + func testFocusedPathRelayFallbackWhenDevFlagOn() async throws { + FuelStore.saveRelayFallbackEnabled(true) + let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) + let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror, relay: relay) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) - XCTAssertEqual(stations.first?.id, "mirror") - XCTAssertEqual(mirror.callCount, 1) - XCTAssertEqual(LiveChainProvider.lastSource, "github") + XCTAssertEqual(stations.first?.id, "relay") + XCTAssertEqual(relay.callCount, 1) + } + + // MARK: Mirror focused projection (replaces the relay's server-side radius) + + func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() { + let here = fixtureStation("here", lat: 53.7, lng: -1.8) // e10+others, at origin + let far = fixtureStation("far", lat: 54.0, lng: -1.8) // ~33 km away, e10 + let noFuel = FuelStation(id: "noFuel", name: "Diesel Only", brand: "Test", + address: "2 High St", postcode: "SW1A 1AA", + lat: 53.7, lng: -1.8, prices: [.diesel: 144.9], priceUpdated: nil) + let d1 = fixtureStation("d1", lat: 53.701, lng: -1.8) + let d2 = fixtureStation("d2", lat: 53.702, lng: -1.8) + + let out = MirrorFuelProvider.focused([far, noFuel, d2, d1, here], + near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 10) + + XCTAssertEqual(out.map(\.id), ["here", "d1", "d2"], "nearest-first, fuel-only, within radius") + XCTAssertFalse(out.contains { $0.id == "far" }, "outside radius must drop") + XCTAssertFalse(out.contains { $0.id == "noFuel" }, "missing fuel must drop") + } + + func testMirrorFocusedPassesThroughWithoutRadius() { + let stations = [fixtureStation("a"), fixtureStation("b")] + XCTAssertEqual(MirrorFuelProvider.focused(stations, near: nil, lng: nil, fuel: .e10, radiusKM: nil).count, 2) + } + + func testMirrorFocusedLimit() { + let stations = (0..<600).map { fixtureStation("s\($0)", lat: 53.7 + Double($0) * 0.0001, lng: -1.8) } + let out = MirrorFuelProvider.focused(stations, near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 100) + XCTAssertEqual(out.count, 500, "focused projection must stay bounded") } // MARK: Mirror day-cache decision @@ -103,7 +163,7 @@ final class LiveChainTests: XCTestCase { XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil)) } - // MARK: Beacon URL + // MARK: Beacon func testBeaconURLCarriesAppAttribution() { guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else { @@ -115,4 +175,12 @@ final class LiveChainTests: XCTestCase { XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github"))) XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022"))) } + + func testBeaconGatedByDevFlag() { + FuelBeacon.isEnabled = true + FuelStore.saveRelayFallbackEnabled(false) + XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)") + FuelStore.saveRelayFallbackEnabled(true) + XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution") + } } diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index d897cad..545cbe6 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -151,6 +151,10 @@ struct FuelPriceTimelineProvider [FuelStation]? { + if let stations = try? await MirrorFuelProvider().fetchStations( + near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) { + return stations + } + guard FuelStore.loadRelayFallbackEnabled() else { return nil } var components = URLComponents( url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false @@ -322,11 +334,16 @@ struct FuelPriceTimelineProvider [FuelStation]? { + if let stations = try? await MirrorFuelProvider().fetchStations( + near: nil, lng: nil, fuel: fuel, radiusKM: nil) { + return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit)) + } + guard FuelStore.loadRelayFallbackEnabled() else { return nil } var components = URLComponents( url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index db19af9..68a4d4f 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -301,6 +301,7 @@ struct FuelStore { static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag + static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) @@ -552,6 +553,22 @@ struct FuelStore { saveString(enabled ? "1" : "0", service: debugModeKey) } + // MARK: Relay fallback (dev-only) + + /// Dev-only switch: re-inserts the LAN relay into the live chain as a + /// fallback for home testing. OFF by default — consumers must never make + /// a local-network attempt (the relay is unreachable off the developer's + /// LAN, and the attempt itself would fire the iOS Local Network prompt). + /// Toggled from Settings → Debug; keychain-first so it survives the + /// delete → reinstall test loop like every other small setting. + static func loadRelayFallbackEnabled() -> Bool { + loadString(service: relayFallbackKey) == "1" + } + + static func saveRelayFallbackEnabled(_ enabled: Bool) { + saveString(enabled ? "1" : "0", service: relayFallbackKey) + } + // MARK: Favourites /// A favourite pins a station FOR ONE fuel type. Starring a row while diff --git a/Shared/MirrorFuelProvider.swift b/Shared/MirrorFuelProvider.swift index e0c789d..e81b477 100644 --- a/Shared/MirrorFuelProvider.swift +++ b/Shared/MirrorFuelProvider.swift @@ -4,22 +4,25 @@ // PRODUCTION data source: its day files are the raw relay /api/v1/stations // envelope, so the shared decode works verbatim. Fetching from // raw.githubusercontent.com makes live prices work fully off-LAN — App -// Review cannot reach the LAN relay — and the bundled dump stays as the -// no-network last resort. +// Review cannot reach a LAN relay, and consumers never have one to reach — +// and the bundled dump (app target) is the no-network last resort. // -// Chain (LiveChainProvider): -// full path (app refresh): GitHub mirror → LAN relay → bundled dump -// focused path (alert fetch): LAN relay (small radius fetch) → GitHub -// mirror (full decode, device filters) → dump -// The focused path stays relay-first so a background alert check never pulls -// the ~2.8 MB full dump over mobile data when the relay is reachable. +// Chain (LiveChainProvider) — GitHub first, EVERYWHERE: +// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the +// ContentView catch, widget: cache → placeholder) +// The relay is NOT part of the consumer chain at all: it is unreachable from +// any phone outside the developer's LAN, and even attempting it from a +// consumer phone would fire the iOS Local Network permission prompt for an +// address that can never be reached. It remains reachable only behind the +// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home +// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror +// push) unchanged. // // Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics -// (page views/clones only), so every chain fetch fires an opportunistic -// fire-and-forget X-Client "app" ping at the relay's existing widget-diag -// route (zero relay changes). On-LAN = attribution + cadence; off-LAN the -// ping fails in ~2 s and the skip itself is the reachability datum. The -// relay's /stats app-hit spike doubles as the fallback-outage signal. +// (page views/clones only), so every chain fetch opportunistically pings the +// relay's existing widget-diag route (zero relay changes) — but ONLY when the +// same dev flag is on, so consumers never make a local-network attempt. +// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active. import Foundation @@ -66,6 +69,21 @@ struct MirrorFuelProvider: FuelPriceProviding { return cachedDay == latestDay } + /// Focused-path projection, replacing what the relay's radius endpoint + /// used to do server-side: stations WITH the fuel, within radius of the + /// point, nearest first, bounded. The alert path re-checks radius and + /// fuel itself afterwards, so this only narrows the payload. + static func focused(_ stations: [FuelStation], near lat: Double?, lng: Double?, + fuel: FuelType, radiusKM: Double?, limit: Int = 500) -> [FuelStation] { + guard let lat, let lng, let radiusKM else { return stations } + return Array( + stations + .filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM } + .sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } + .prefix(limit) + ) + } + func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { // 1. Tiny pointer fetch — the freshest snapshot day. guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL), @@ -89,16 +107,18 @@ struct MirrorFuelProvider: FuelPriceProviding { } let stations = try FuelPriceProvider.decodeStations(from: data) Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) - return stations + return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) } } // MARK: - Live chain provider -/// The live price chain. Full path prefers the GitHub mirror; the focused -/// path (alert checks) stays relay-first so a background alert never pulls -/// the full dump over mobile data when the relay is up. Whichever leg serves -/// is recorded for telemetry + the About section. +/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part +/// of the consumer chain: consumers can never reach it (and must never even +/// attempt it — that would prompt for Local Network access on a stranger's +/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback` +/// (Settings → Debug) for the developer's own home testing. Whichever leg +/// serves is recorded for the About section + telemetry. struct LiveChainProvider: FuelPriceProviding { var mirror: FuelPriceProviding var relay: FuelPriceProviding @@ -114,23 +134,6 @@ struct LiveChainProvider: FuelPriceProviding { } func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { - if radiusKM != nil { - // Focused alert fetch: light relay radius call first, mirror fallback. - do { - let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) - Self.latestMeta = RelayFuelProvider.latestMeta - Self.lastSource = "relay" - FuelBeacon.fire(source: "relay", n: stations.count) - return stations - } catch { - let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) - Self.latestMeta = MirrorFuelProvider.latestMeta - Self.lastSource = "github" - FuelBeacon.fire(source: "github", n: stations.count) - return stations - } - } - // Full dump: GitHub first (off-LAN + App Review), relay fallback. do { let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) Self.latestMeta = MirrorFuelProvider.latestMeta @@ -138,6 +141,9 @@ struct LiveChainProvider: FuelPriceProviding { FuelBeacon.fire(source: "github", n: stations.count) return stations } catch { + // Dev-only fallback: consumers never reach this branch — the flag + // is off by default and the flag itself lives in the Debug section. + guard FuelStore.loadRelayFallbackEnabled() else { throw error } let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) Self.latestMeta = RelayFuelProvider.latestMeta Self.lastSource = "relay" @@ -150,13 +156,19 @@ struct LiveChainProvider: FuelPriceProviding { // MARK: - Telemetry beacon /// Fire-and-forget attribution for the LIVE chain (see header note). Never -/// awaited, never user-visible; a failed ping (off-LAN) is silent and IS the -/// reachability datum. Disabled in unit tests via `isEnabled`. +/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the +/// dev relay flag is on — consumers must never make a local-network attempt, +/// and an off-LAN ping failing silently is itself the reachability datum. +/// Disabled in unit tests via `isEnabled`. enum FuelBeacon { static var isEnabled = true + static var shouldFire: Bool { + isEnabled && FuelStore.loadRelayFallbackEnabled() + } + static func fire(source: String, n: Int, timeout: TimeInterval = 2) { - guard isEnabled, + guard shouldFire, let url = beaconURL(source: source, n: n) else { return } var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout) request.cachePolicy = .reloadIgnoringLocalCacheData