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