Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone

The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.

- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
  longer relay-first); LAN relay only joins behind the hidden dev flag
  fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
  radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
  — the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
  shared through the app group); relay + widget-diag GET gated on the same
  dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
  local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
  no 12 s stall) — data page is now informational: prices download from the
  internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
  focused projection x3, beacon dev-flag gate)
This commit is contained in:
FuelBoard Contributor
2026-08-15 13:33:02 +01:00
parent c447372d40
commit 535a504160
8 changed files with 196 additions and 264 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.</string>
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key>
+9 -194
View File
@@ -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
+7
View File
@@ -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: {
+1 -5
View File
@@ -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 */
@@ -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")
}
}
+24 -7
View File
@@ -151,6 +151,10 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
"""
FuelStore.saveWidgetDiag(json, intentType: intentType)
// The relay GET is dev-only (same flag as the app beacon): consumers
// must never make a local-network attempt. The local app-group diag
// write above stays Settings Widget Diagnostics reads that.
guard FuelStore.loadRelayFallbackEnabled() else { return }
guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
@@ -297,10 +301,18 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
)
}
/// One focused fetch from the relay around the widget's location small
/// response (radius + limit 500), bounded so a dead relay can't stall the
/// timeline. Returns nil on any failure so callers fall back to cache.
/// One focused fetch around the widget's location. GitHub mirror FIRST
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
/// local decode rather than a 2.8 MB download. The LAN relay only joins
/// behind the dev flag (fuelboard.relayFallback); consumers must never
/// attempt local-network access. Returns nil on any failure so callers
/// fall back to cache/placeholder.
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [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<Configuration: WidgetConfigurationIntent & Widg
}
}
/// Fuel-only UK-wide fetch (no lat/lng/radius) the relay returns the
/// cheapest-first dataset for the fuel, bounded by limit. Used when the
/// widget has no location fix so the face shows real stations instead of
/// sample data. Same timeout/bounded semantics as fetchFocused.
/// Fuel-only UK-wide fetch (no lat/lng/radius) used when the widget has
/// no location fix so the face shows real stations instead of sample
/// data. GitHub mirror first (full dump + local fuel filter), relay only
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [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
+17
View File
@@ -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
+50 -38
View File
@@ -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