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)
193 lines
8.9 KiB
Swift
193 lines
8.9 KiB
Swift
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
|
//
|
|
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
|
|
// 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 a LAN relay, and consumers never have one to reach —
|
|
// and the bundled dump (app target) is the no-network last resort.
|
|
//
|
|
// 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 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
|
|
|
|
// MARK: - Mirror provider (GitHub raw)
|
|
|
|
/// Fetches CURRENT prices from the GitHub mirror. Same envelope as the
|
|
/// relay, so `FuelPriceProvider.decodeStations` applies unchanged.
|
|
struct MirrorFuelProvider: FuelPriceProviding {
|
|
var baseURL = FuelHistoryStore.mirrorBase
|
|
|
|
/// Envelope metadata from the most recent successful mirror fetch —
|
|
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
|
static var latestMeta: RelayMeta?
|
|
|
|
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
|
/// The mirror pushes once/day, so between pushes the app reuses the
|
|
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
|
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
|
|
|
struct DumpCache: Codable {
|
|
let day: String
|
|
let data: Data
|
|
}
|
|
|
|
static func loadDumpCache() -> DumpCache? {
|
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
|
let raw = defaults.data(forKey: liveDumpCacheKey),
|
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
|
return nil
|
|
}
|
|
return cache
|
|
}
|
|
|
|
static func saveDumpCache(day: String, data: Data) {
|
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
|
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
|
defaults.set(raw, forKey: liveDumpCacheKey)
|
|
}
|
|
|
|
/// True when the cached dump is already the freshest the mirror has —
|
|
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
|
|
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
|
|
guard let cachedDay, let latestDay else { return false }
|
|
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),
|
|
let day = latest.availableTo ?? latest.date else {
|
|
throw FuelProviderError.mirrorUnavailable
|
|
}
|
|
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
|
|
let cache = Self.loadDumpCache()
|
|
let data: Data
|
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
|
data = cached
|
|
} else {
|
|
let (fetched, response) = try await URLSession.shared.data(
|
|
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
|
)
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
|
throw FuelProviderError.mirrorUnavailable
|
|
}
|
|
Self.saveDumpCache(day: day, data: fetched)
|
|
data = fetched
|
|
}
|
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
|
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
|
}
|
|
}
|
|
|
|
// MARK: - Live chain provider
|
|
|
|
/// 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
|
|
|
|
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
|
static var latestMeta: RelayMeta?
|
|
static var lastSource: String?
|
|
|
|
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
|
relay: FuelPriceProviding = RelayFuelProvider()) {
|
|
self.mirror = mirror
|
|
self.relay = relay
|
|
}
|
|
|
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
|
do {
|
|
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
|
|
} 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"
|
|
FuelBeacon.fire(source: "relay", n: stations.count)
|
|
return stations
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Telemetry beacon
|
|
|
|
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
|
|
/// 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 shouldFire,
|
|
let url = beaconURL(source: source, n: n) else { return }
|
|
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
Task { _ = try? await URLSession.shared.data(for: request) }
|
|
}
|
|
|
|
/// Testable URL construction — hits the relay's existing widget-diag
|
|
/// beacon route with app attribution (zero relay changes).
|
|
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
|
guard var components = URLComponents(
|
|
url: base.appendingPathComponent("api/v1/widget-diag"),
|
|
resolvingAgainstBaseURL: false
|
|
) else { return nil }
|
|
components.queryItems = [
|
|
URLQueryItem(name: "intent", value: "app-live"),
|
|
URLQueryItem(name: "source", value: source),
|
|
URLQueryItem(name: "n", value: String(n)),
|
|
]
|
|
return components.url
|
|
}
|
|
}
|