Harden App Store build for review

This commit is contained in:
FuelBoard Contributor
2026-10-01 12:19:05 +01:00
parent c7731d2b36
commit 50c543c776
14 changed files with 177 additions and 412 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
// FuelHistory.swift — price-history store for the Favourites Trends graph.
//
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
// ingestion relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
// The store keeps a favourites-only day cache in the app group so the chart
// works offline once a day has been seen, and only fetches the days it is
// missing. Foundation-only so the unit-test target can compile it on macOS.
@@ -66,8 +66,8 @@ enum FuelHistoryError: LocalizedError {
// MARK: - Store
enum FuelHistoryStore {
/// GitHub raw mirror base — the app reads history directly from here;
/// the LAN relay remains the live-data fallback.
/// GitHub raw mirror base — the app reads history and live data directly from here.
/// No private LAN relay fallback ships in the app binary.
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
static let historyCacheKey = "fuelboard.historyCache"
static let maxCachedDays = 90
+23 -75
View File
@@ -19,14 +19,15 @@ protocol FuelPriceProviding {
}
enum FuelPriceProvider {
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
/// relay (fallback) → bundled dump (last resort in the app target).
/// Default: the live chain — public GitHub mirror (primary, off-LAN) →
/// bundled dump/cache fallback in callers. No private LAN fallback ships in
/// the app binary.
static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a relay payload into stations, applying the defensive price
/// Decodes a mirror payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard.
static func decodeStations(from data: Data) throws -> [FuelStation] {
let payload = try JSONDecoder().decode(RelayResponse.self, from: data)
let payload = try JSONDecoder().decode(MirrorResponse.self, from: data)
return payload.stations.map { relay in
FuelStation(
id: relay.id ?? relay.name ?? UUID().uuidString,
@@ -42,13 +43,13 @@ enum FuelPriceProvider {
}
}
/// Price sanity band (pence/litre) — anything outside is relay regression
/// Price sanity band (pence/litre) — anything outside is mirror regression
/// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500
/// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive
/// band guard. Shared by the relay decode and the history mirror parser so
/// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive
/// band guard. Shared by the mirror decode and the history mirror parser so
/// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:]
@@ -64,12 +65,12 @@ enum FuelPriceProvider {
return result
}
/// Decodes the relay envelope metadata (source, dataset update time) — the
/// Decodes mirror envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive
/// on the relay, so older relays simply return nil values.
/// on the mirror, so older snapshots simply return nil values.
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else {
guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else {
return nil
}
return RelayMeta(
@@ -80,64 +81,11 @@ enum FuelPriceProvider {
}
}
// MARK: - Relay provider (default)
// MARK: - Mirror payload envelope
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
/// filters/sorts server-side. No credentials in the app.
struct RelayFuelProvider: FuelPriceProviding {
var baseURL = URL(string: "http://192.168.1.131:8789")!
/// Envelope metadata from the most recent successful relay fetch —
/// consumed by Settings → About (source api/csv, station count, GOV.UK
/// dataset update time). Written by fetchStations, read by the view.
static var latestMeta: RelayMeta?
/// Admin telemetry: the relay tags inbound connections by client kind
/// (app vs widget) so the developer can monitor the poll model. Siri
/// never fetches (cached-data-first). Purely observational — the relay
/// ignores the header for responses.
static let clientTagHeader = "X-Client"
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
request.setValue(client, forHTTPHeaderField: clientTagHeader)
return request
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
if let lat, let lng {
query.append(URLQueryItem(name: "lat", value: String(lat)))
query.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let radiusKM {
// Focused fetch (alert path) — small response, server-side radius.
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
query.append(URLQueryItem(name: "limit", value: "500"))
} else {
// Full-UK dump: NO radius — the device filters locally
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
query.append(URLQueryItem(name: "limit", value: "10000"))
}
components.queryItems = query
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable
}
let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return stations
}
}
private struct RelayResponse: Codable {
private struct MirrorResponse: Codable {
/// Envelope metadata surfaced in Settings → About so the user can see
/// which relay source is live and how fresh the GOV.UK data is.
/// which mirror source is live and how fresh the GOV.UK data is.
let source: String?
let stationsCount: Int?
let dataUpdated: String?
@@ -148,7 +96,7 @@ private struct RelayResponse: Codable {
case dataUpdated = "data_updated"
}
struct RelayStation: Codable {
struct MirrorStation: Codable {
let id: String?
let name: String?
let brand: String?
@@ -159,24 +107,24 @@ private struct RelayResponse: Codable {
let price: Double?
let prices: [String: Double]?
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard:
/// any grade outside the 50–500 pence/litre band is dropped, so a relay
/// regression (e.g. the band being removed server-side) can't re-poison
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
/// regression (e.g. the band being removed upstream) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] {
var result = FuelPriceProvider.mapGrades(prices ?? [:])
// Backwards-compat: relay versions without `prices` still send `price`.
// Backwards-compat: mirror versions without `prices` still send `price`.
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price
}
return result
}
}
let stations: [RelayStation]
let stations: [MirrorStation]
}
/// Envelope metadata from the relay response — which data source is live
/// Envelope metadata from the mirror response — which data source is live
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
/// itself reports for the dataset. Displayed in Settings → About.
struct RelayMeta: Codable, Equatable {
@@ -189,7 +137,7 @@ struct RelayMeta: Codable, Equatable {
/// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country — it is NOT tied to one town.
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API).
/// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
@@ -302,7 +250,7 @@ enum FuelProviderError: LocalizedError {
case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable:
return "FuelBoard Relay unreachable — showing cached/sample data."
return "FuelBoard live data unavailable — showing cached/sample data."
case .mirrorUnavailable:
return "GitHub price mirror unreachable."
}
-17
View File
@@ -407,7 +407,6 @@ struct FuelStore {
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
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)
@@ -711,22 +710,6 @@ 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
+19 -87
View File
@@ -1,28 +1,16 @@
// 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.
// The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay)
// is the PRODUCTION data source: its day files use the same /api/v1/stations
// envelope, so the shared decoder works verbatim. Fetching from
// raw.githubusercontent.com makes live prices work fully off-LAN — App Review
// cannot reach a private LAN relay, and consumers never have one to reach —
// with the bundled dump (app target) as 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.
// Chain (LiveChainProvider) — public HTTPS only:
// GitHub mirror → (app target: bundled dump in the ContentView catch,
// widget: cache → placeholder)
// No local-network fallback or private IP is compiled into the app binary.
import Foundation
@@ -126,80 +114,24 @@ struct MirrorFuelProvider: FuelPriceProviding {
// 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.
/// The live price chain — public GitHub mirror only. No private LAN fallback is
/// compiled into the app binary, so App Store/TestFlight builds cannot trigger
/// Local Network permission or leak a private IP.
struct LiveChainProvider: FuelPriceProviding {
var mirror: FuelPriceProviding
var relay: FuelPriceProviding
/// Which leg served the last fetch — Settings → About metadata + beacon.
/// Metadata surfaced in Settings → About.
static var latestMeta: RelayMeta?
static var lastSource: String?
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
relay: FuelPriceProviding = RelayFuelProvider()) {
init(mirror: FuelPriceProviding = MirrorFuelProvider()) {
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
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = MirrorFuelProvider.latestMeta
Self.lastSource = "github"
return stations
}
}