Compare commits
8
Commits
54aca14fd4
...
44a8e7f42a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a8e7f42a | ||
|
|
b634c0686d | ||
|
|
485ef782b6 | ||
|
|
b072b62208 | ||
|
|
10ab1cebef | ||
|
|
535a504160 | ||
|
|
c447372d40 | ||
|
|
366e21a1b4 |
+14
-35
@@ -9,41 +9,6 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P0 — Next build
|
|
||||||
|
|
||||||
- [ ] **GitHub price-history mirror (warm the data for Trends)** — the relay does
|
|
||||||
the GOV.UK heavy lifting (OAuth token stays in relay `.env`) and pushes a daily
|
|
||||||
snapshot to a PUBLIC repo; the app reads from GitHub, so this doubles as the
|
|
||||||
production data source (App Review can't reach the LAN relay) AND the history
|
|
||||||
archive the Trends graph (P1) reads. Shape: relay writes `latest.json` (tiny
|
|
||||||
pointer: date, station_count, data_updated, available range) +
|
|
||||||
`history/YYYY-MM-DD.json` (FULL dump — 2.58 MB/day, decision Option 2) and
|
|
||||||
pushes once/day; the app fetches
|
|
||||||
`raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json`, then only
|
|
||||||
the missing day files for starred (station, fuel) pairs (deterministic URLs —
|
|
||||||
404 = no data that day, leave a graph gap, never error). LAN relay stays the
|
|
||||||
fallback source. Telemetry implication: raw GitHub fetches are invisible to repo
|
|
||||||
analytics (page views/clones only) — keep the widget-diag beacon firing
|
|
||||||
regardless of data source (build/stale-build checks survive) and add an
|
|
||||||
opportunistic fire-and-forget X-Client app beacon on fetch (LAN = attribution +
|
|
||||||
cadence; off-LAN skip is itself a reachability datum). `/stats` keeps the
|
|
||||||
intrusion canary and gains a new signal: app-hit spikes = GitHub path failing →
|
|
||||||
fallback active. App Review: public repo required (reviewer fetches the data
|
|
||||||
themselves); HTTPS/ATS fine; plain JSON is not "remote code" (2.5.2-safe); bundle
|
|
||||||
a small fallback dump so the app never demos empty. Retention: 180 days (~470 MB,
|
|
||||||
pruning enforced by the push script — full dump, NOT the earlier ~200 KB
|
|
||||||
price-only estimate). *PUSH SIDE DONE 2026-08-15 — repo live
|
|
||||||
(https://github.com/aptonline/fuelboard-data); auth = SSH deploy key
|
|
||||||
~/.ssh/fuelboard_deploy via alias github-fuelboard-data (no token anywhere);
|
|
||||||
clone ~/workspace/fuelboard-data; launchd com.apt.fuelboard-mirror daily 10:00
|
|
||||||
(script ~/.hermes/scripts/mirror_push.py, log
|
|
||||||
~/Library/Logs/fuelboard-mirror.log); manual trigger `launchctl start
|
|
||||||
com.apt.fuelboard-mirror`; first snapshot 2026-08-15 landed + verified raw 200.
|
|
||||||
REMAINING: live provider chain (GitHub → relay → bundled dump for CURRENT
|
|
||||||
prices) + the telemetry beacon — NOTE: the HISTORY read path shipped with P1
|
|
||||||
(FuelHistoryStore: latest.json + day files for starred stations, favourites-only
|
|
||||||
app-group cache, 404 = gap never error, 90-day prune).*
|
|
||||||
|
|
||||||
## P1 — Soon
|
## P1 — Soon
|
||||||
|
|
||||||
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
|
||||||
@@ -164,6 +129,20 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
|
|
||||||
## Done (recent)
|
## Done (recent)
|
||||||
|
|
||||||
|
- [x] **GitHub price-history mirror + LIVE provider chain (P0, DONE 2026-08-16)** —
|
||||||
|
push side (relay → public `aptonline/fuelboard-data`, daily 10:00 launchd, SSH
|
||||||
|
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
||||||
|
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||||
|
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||||
|
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
||||||
|
unreachable from any consumer phone and attempting it would fire the Local
|
||||||
|
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
||||||
|
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
||||||
|
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
||||||
|
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
||||||
|
ping fires only with the dev flag on. **Local Network permission removed from
|
||||||
|
onboarding entirely** — data page is informational, replay on cellular can no
|
||||||
|
longer hang. 95 tests.
|
||||||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||||||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||||||
live in relay `.env` only, never in the IPA
|
live in relay `.env` only, never in the IPA
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<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>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"data" : [
|
||||||
|
{
|
||||||
|
"filename" : "fuelboard_dump.json",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
// BundledDumpProvider.swift — no-network last resort (P0 live chain).
|
||||||
|
//
|
||||||
|
// A REAL snapshot of the mirror dump is bundled into the app (FuelBoardDump
|
||||||
|
// dataset, refreshed before builds by scripts/refresh_bundled_dump.sh) so the
|
||||||
|
// app never demos empty and App Review always sees real data even fully
|
||||||
|
// offline. Same relay envelope, so the shared decode applies unchanged.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum BundledDumpProvider {
|
||||||
|
/// The bundled real snapshot, decoded with the shared relay decode —
|
||||||
|
/// nil only if the asset is missing or corrupt (callers then fall back
|
||||||
|
/// to the England-wide sample set).
|
||||||
|
static var stations: [FuelStation]? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
|
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -454,9 +454,10 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
// Persist relay envelope metadata (source, station count, GOV.UK
|
// Persist envelope metadata (source, station count, GOV.UK
|
||||||
// dataset update time) for the Settings → About section.
|
// dataset update time) for the Settings → About section — the
|
||||||
if let meta = RelayFuelProvider.latestMeta {
|
// live chain records whichever leg served the fetch.
|
||||||
|
if let meta = LiveChainProvider.latestMeta {
|
||||||
FuelStore.saveRelayMeta(meta)
|
FuelStore.saveRelayMeta(meta)
|
||||||
}
|
}
|
||||||
// Keep the keychain favourites fresh with the new prices — the
|
// Keep the keychain favourites fresh with the new prices — the
|
||||||
@@ -467,8 +468,14 @@ struct ContentView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
|
if FuelStore.loadStations().isEmpty {
|
||||||
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
|
// (stale but genuine), then the demo sample set.
|
||||||
|
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||||||
|
} else {
|
||||||
|
stations = FuelStore.loadStations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
|||||||
+27
-206
@@ -47,40 +47,30 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
// Auto-advance once the user grants a permission — the prompt itself
|
// Auto-advance once the user grants a permission.
|
||||||
// only fires when the Continue button is tapped (after reading the
|
|
||||||
// page's description), never when the page merely appears.
|
|
||||||
.onChange(of: prompter.locationGranted) { _, granted in
|
.onChange(of: prompter.locationGranted) { _, granted in
|
||||||
if granted, page == 1 { page = 2 }
|
if granted, page == 1 { page = 2 }
|
||||||
}
|
}
|
||||||
.onChange(of: prompter.notificationsGranted) { _, granted in
|
.onChange(of: prompter.notificationsGranted) { _, granted in
|
||||||
if granted, page == 2 { page = 3 }
|
if granted, page == 2 { page = 3 }
|
||||||
}
|
}
|
||||||
.onChange(of: prompter.dataGranted) { _, granted in
|
// Fire each permission when the user LEAVES its page by advancing
|
||||||
if granted, page == 3 {
|
// forward (swipe or Continue) — by then the screen has been read.
|
||||||
// The probe result lands while the Local Network prompt is
|
// Never on arrival: a fast swipe can't stack prompts before a page
|
||||||
// still dismissing, so without a beat the green "Connected"
|
// is seen. Forward-only: swiping back never fires. Idempotent —
|
||||||
// tick is skipped and the page jumps straight to Done —
|
// an already-determined permission just refreshes, so the
|
||||||
// unlike the Location/Notifications pages where the granted
|
// auto-advance path and replays stay clean (no duplicate alerts).
|
||||||
// tick is visible. Hold the tick on screen, then advance.
|
.onChange(of: page) { oldPage, newPage in
|
||||||
Task { @MainActor in
|
guard newPage > oldPage else { return }
|
||||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
if oldPage == 1 {
|
||||||
if page == 3 { page = 4 }
|
prompter.requestLocation()
|
||||||
}
|
} else if oldPage == 2 {
|
||||||
}
|
prompter.requestNotifications()
|
||||||
}
|
|
||||||
// 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(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
||||||
@@ -207,47 +197,17 @@ struct OnboardingView: View {
|
|||||||
Text("Prices, ready when you are")
|
Text("Prices, ready when you are")
|
||||||
.font(.largeTitle.bold())
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
dataStatusLabel
|
|
||||||
.padding(.top, 24)
|
|
||||||
|
|
||||||
Spacer()
|
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 {
|
private var donePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -288,11 +248,10 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
} else if !prompter.locationGranted {
|
|
||||||
// The system prompt fires HERE — after the user has read
|
|
||||||
// the description and tapped Continue — not on page appear.
|
|
||||||
prompter.requestLocation()
|
|
||||||
} else {
|
} else {
|
||||||
|
// The prompt fires on LEAVING this page (onChange(of:
|
||||||
|
// page), forward advance only) — the description is on
|
||||||
|
// screen until the user moves on.
|
||||||
page = 2
|
page = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,27 +259,12 @@ struct OnboardingView: View {
|
|||||||
primaryButton(
|
primaryButton(
|
||||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||||
) {
|
) {
|
||||||
if prompter.notificationsDenied {
|
// Prompt fires on leaving this page (same rule as Location).
|
||||||
page = 3
|
page = 3
|
||||||
} else if !prompter.notificationsGranted {
|
|
||||||
prompter.requestNotifications()
|
|
||||||
} else {
|
|
||||||
page = 3
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
|
// No permission on this page — prices need none. Straight on.
|
||||||
if prompter.dataGranted || prompter.dataDenied {
|
primaryButton("Continue") { page = 4 }
|
||||||
// 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)
|
|
||||||
default:
|
default:
|
||||||
primaryButton("Start Using FuelBoard") { finish() }
|
primaryButton("Start Using FuelBoard") { finish() }
|
||||||
}
|
}
|
||||||
@@ -390,13 +334,6 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
|||||||
@Published private(set) var locationDenied = false
|
@Published private(set) var locationDenied = false
|
||||||
@Published private(set) var notificationsGranted = false
|
@Published private(set) var notificationsGranted = false
|
||||||
@Published private(set) var notificationsDenied = 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()
|
private let manager = CLLocationManager()
|
||||||
|
|
||||||
@@ -438,125 +375,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggers the Local Network permission prompt WITHOUT loading any data.
|
/// Replay pre-flight: NO data permission exists anymore — prices download
|
||||||
/// iOS shows the prompt on the first attempt to reach a local-network
|
/// from the internet with no prompt, so there is nothing to preflight.
|
||||||
/// address, so a bare TCP connect to the relay host:port is enough — no
|
/// (The LAN relay left the consumer chain; Local Network is gone.)
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ struct SettingsView: View {
|
|||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
@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 versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
@@ -129,6 +132,10 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
if debugMode {
|
if debugMode {
|
||||||
Section {
|
Section {
|
||||||
|
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
|
||||||
|
.onChange(of: relayFallbackEnabled) { _, enabled in
|
||||||
|
FuelStore.saveRelayFallbackEnabled(enabled)
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
sendTestAlert()
|
sendTestAlert()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -29,27 +29,33 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
|
/// current list pool; the denominator is the full UK station total from
|
||||||
|
/// the last chain fetch (8,022 mirror snapshot) — hidden until known.
|
||||||
|
private var footerCaption: String {
|
||||||
|
let miles = distanceUnit.displayMiles(stationLimit)
|
||||||
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
|
let fuel = selectedFuel.displayName
|
||||||
|
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||||
|
let ratio: String
|
||||||
|
if let total = FuelStore.loadStationCount() {
|
||||||
|
ratio = "\(totalCount)/\(total)"
|
||||||
|
} else {
|
||||||
|
ratio = "\(totalCount)"
|
||||||
|
}
|
||||||
|
if let location {
|
||||||
|
if sortMode == .closest {
|
||||||
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
|
}
|
||||||
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
||||||
|
}
|
||||||
|
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
|
||||||
if let location {
|
|
||||||
if sortMode == .closest {
|
|
||||||
Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) · \(totalCount) stations")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("Cheapest \(selectedFuel.displayName) within \(distanceUnit.displayMiles(stationLimit)) \(distanceUnit.label(for: Double(distanceUnit.displayMiles(stationLimit)))) — \(totalCount) stations · tap a station for directions.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("\(sortMode == .closest ? "Closest" : "Cheapest") \(selectedFuel.displayName) — tap a station for directions.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Fuel type") {
|
Section("Fuel type") {
|
||||||
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
|
FuelTypeSegmentedPicker(selection: $selectedFuel) { newValue in
|
||||||
FuelStore.saveSelectedFuel(newValue)
|
FuelStore.saveSelectedFuel(newValue)
|
||||||
@@ -131,6 +137,15 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom-of-tab explainer (moved from the top 2026-08-16),
|
||||||
|
// styled like the Favourites tab's cheapest callout: a caption
|
||||||
|
// paragraph in its own section cell.
|
||||||
|
Section {
|
||||||
|
Text(footerCaption)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
// Manual override for the twice-a-day cache policy.
|
// Manual override for the twice-a-day cache policy.
|
||||||
|
|||||||
@@ -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.";
|
"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.";
|
"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";
|
"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";
|
"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.";
|
||||||
"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.";
|
|
||||||
"You're all set" = "You're all set";
|
"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.";
|
"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";
|
"Continue" = "Continue";
|
||||||
@@ -124,7 +121,6 @@
|
|||||||
"Allow Location Access" = "Allow Location Access";
|
"Allow Location Access" = "Allow Location Access";
|
||||||
"Continue without alerts" = "Continue without alerts";
|
"Continue without alerts" = "Continue without alerts";
|
||||||
"Allow Notifications" = "Allow Notifications";
|
"Allow Notifications" = "Allow Notifications";
|
||||||
"Waiting…" = "Waiting…";
|
|
||||||
"Start Using FuelBoard" = "Start Using FuelBoard";
|
"Start Using FuelBoard" = "Start Using FuelBoard";
|
||||||
|
|
||||||
/* Station map */
|
/* Station map */
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/MirrorFuelProvider.swift
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
FuelBeacon.isEnabled = true
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fixtures
|
||||||
|
|
||||||
|
private final class StubProvider: FuelPriceProviding {
|
||||||
|
var result: [FuelStation]
|
||||||
|
var error: Error?
|
||||||
|
private(set) var callCount = 0
|
||||||
|
|
||||||
|
init(result: [FuelStation]? = nil, error: Error? = nil) {
|
||||||
|
self.result = result ?? []
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
callCount += 1
|
||||||
|
if let error { throw error }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: lat, lng: lng,
|
||||||
|
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Full path (app refresh): GitHub first
|
||||||
|
|
||||||
|
func testFullPathPrefersMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation()])
|
||||||
|
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.count, 1)
|
||||||
|
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
|
||||||
|
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "relay")
|
||||||
|
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Focused path (alert checks + widget): GitHub first too
|
||||||
|
|
||||||
|
func testFocusedPathPrefersMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||||
|
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", "focused path must prefer the mirror — the relay is unreachable off-LAN")
|
||||||
|
XCTAssertEqual(relay.callCount, 0)
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "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
|
||||||
|
|
||||||
|
func testDumpCacheReuseDecision() {
|
||||||
|
XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"),
|
||||||
|
"same day → reuse the cached dump, no ~2.8 MB re-download")
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Beacon
|
||||||
|
|
||||||
|
func testBeaconURLCarriesAppAttribution() {
|
||||||
|
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
|
||||||
|
return XCTFail("beacon URL must build")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag"))
|
||||||
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||||
|
XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live")))
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,10 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
"station":"\(first?.name ?? "")","price":\(first?.prices[entry.fuel] ?? -1)}
|
||||||
"""
|
"""
|
||||||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
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(
|
guard var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
@@ -297,10 +301,18 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One focused fetch from the relay around the widget's location — small
|
/// One focused fetch around the widget's location. GitHub mirror FIRST —
|
||||||
/// response (radius + limit 500), bounded so a dead relay can't stall the
|
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
|
||||||
/// timeline. Returns nil on any failure so callers fall back to cache.
|
/// 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]? {
|
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(
|
var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
@@ -322,11 +334,16 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-only UK-wide fetch (no lat/lng/radius) — the relay returns the
|
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
|
||||||
/// cheapest-first dataset for the fuel, bounded by limit. Used when the
|
/// no location fix so the face shows real stations instead of sample
|
||||||
/// widget has no location fix so the face shows real stations instead of
|
/// data. GitHub mirror first (full dump + local fuel filter), relay only
|
||||||
/// sample data. Same timeout/bounded semantics as fetchFocused.
|
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
|
||||||
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
|
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(
|
var components = URLComponents(
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ protocol FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelPriceProvider {
|
enum FuelPriceProvider {
|
||||||
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
|
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
|
||||||
/// the England-wide sample set when the relay is unreachable.
|
/// relay (fallback) → bundled dump (last resort in the app target).
|
||||||
static let active: FuelPriceProviding = RelayFuelProvider()
|
static let active: FuelPriceProviding = LiveChainProvider()
|
||||||
|
|
||||||
/// Decodes a relay payload into stations, applying the defensive price
|
/// Decodes a relay payload into stations, applying the defensive price
|
||||||
/// band. Internal so the unit-test target can exercise the guard.
|
/// band. Internal so the unit-test target can exercise the guard.
|
||||||
@@ -296,12 +296,15 @@ struct FuelFinderProvider: FuelPriceProviding {
|
|||||||
enum FuelProviderError: LocalizedError {
|
enum FuelProviderError: LocalizedError {
|
||||||
case notImplemented
|
case notImplemented
|
||||||
case relayUnavailable
|
case relayUnavailable
|
||||||
|
case mirrorUnavailable
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notImplemented:
|
case .notImplemented:
|
||||||
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
||||||
case .relayUnavailable:
|
case .relayUnavailable:
|
||||||
return "FuelBoard Relay unreachable — showing cached/sample data."
|
return "FuelBoard Relay unreachable — showing cached/sample data."
|
||||||
|
case .mirrorUnavailable:
|
||||||
|
return "GitHub price mirror unreachable."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ struct FuelStore {
|
|||||||
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
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 liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
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 liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
||||||
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
@@ -552,6 +553,22 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: debugModeKey)
|
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
|
// MARK: Favourites
|
||||||
|
|
||||||
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Refresh the bundled fallback dump (FuelBoardDump dataset) from the GitHub
|
||||||
|
# mirror — run before a Release build so the IPA carries the freshest REAL
|
||||||
|
# snapshot (the app's no-network last resort). Idempotent: skips when the
|
||||||
|
# bundled dump already carries the mirror's data_updated stamp.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
TARGET="FuelBoard/Assets.xcassets/FuelBoardDump.dataset/fuelboard_dump.json"
|
||||||
|
RAW="https://raw.githubusercontent.com/aptonline/fuelboard-data/main"
|
||||||
|
LATEST="$(curl -fsSL "$RAW/latest.json")"
|
||||||
|
DAY="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("available_to") or d.get("date") or "")')"
|
||||||
|
UPD="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("data_updated") or "")')"
|
||||||
|
if [ -z "$DAY" ]; then echo "no snapshot day in latest.json" >&2; exit 1; fi
|
||||||
|
if [ -f "$TARGET" ] && grep -q "\"data_updated\": \"$UPD\"" "$TARGET"; then
|
||||||
|
echo "bundled dump already current ($DAY, $UPD)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "fetching history/$DAY.json ($UPD)…"
|
||||||
|
curl -fsSL "$RAW/history/$DAY.json" -o "$TARGET"
|
||||||
|
echo "updated $TARGET — $DAY"
|
||||||
Reference in New Issue
Block a user