// 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 } }