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
+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