Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone

The LAN relay is unreachable from any phone outside the developer's LAN, so
for App Store users a relay fallback was dead weight AND harmful: attempting
the private IP fires the iOS Local Network prompt on a stranger's phone and
adds a timeout before the bundled dump. GitHub raw is reachable anywhere the
relay would be, and more reliably.

- LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no
  longer relay-first); LAN relay only joins behind the hidden dev flag
  fuelboard.relayFallback (Settings → Debug, keychain-first, off by default)
- MirrorFuelProvider: focused() projection replaces the relay's server-side
  radius endpoint (fuel filter + within-radius + nearest-first + limit 500)
  — the alert path re-checks radius/fuel itself, so behaviour is identical
- Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache
  shared through the app group); relay + widget-diag GET gated on the same
  dev flag — off-LAN widgets no longer attempt local network at all
- FuelBeacon: fires only when the dev flag is on — consumers make zero
  local-network attempts; /stats app-hit spike = GitHub down + dev flag on
- Onboarding: Local Network machinery removed entirely (no probe, no prompt,
  no 12 s stall) — data page is now informational: prices download from the
  internet, no permissions needed. Replay on cellular can no longer hang.
- Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording
- 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror
  focused projection x3, beacon dev-flag gate)
This commit is contained in:
FuelBoard Contributor
2026-08-15 13:33:02 +01:00
parent c447372d40
commit 535a504160
8 changed files with 196 additions and 264 deletions
+1 -1
View File
@@ -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>
+9 -194
View File
@@ -56,31 +56,9 @@ struct OnboardingView: View {
.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 // No data-permission step: prices download from the internet (GitHub
if granted, page == 3 { // mirror) with no permission at all Local Network was removed from
// The probe result lands while the Local Network prompt is // the app when the relay left the consumer chain.
// still dismissing, so without a beat the green "Connected"
// tick is skipped and the page jumps straight to Done
// unlike the Location/Notifications pages where the granted
// tick is visible. Hold the tick on screen, then advance.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 800_000_000)
if page == 3 { page = 4 }
}
}
}
// 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()
}
}
.background( .background(
LinearGradient( LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)], colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
@@ -207,47 +185,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()
@@ -309,18 +257,8 @@ struct OnboardingView: View {
} }
} }
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 +328,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 +369,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
+7
View File
@@ -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: {
+1 -5
View File
@@ -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 */
@@ -1,12 +1,16 @@
import XCTest import XCTest
@testable import FuelBoardShared @testable import FuelBoardShared
/// P0 live chain: GitHub mirror LAN relay bundled dump. /// 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 { final class LiveChainTests: XCTestCase {
override func setUp() { override func setUp() {
super.setUp() super.setUp()
FuelBeacon.isEnabled = false // chain tests must not fire real pings FuelBeacon.isEnabled = false // chain tests must not fire real pings
FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default
} }
override func tearDown() { override func tearDown() {
@@ -33,9 +37,9 @@ final class LiveChainTests: XCTestCase {
} }
} }
private func fixtureStation(_ id: String = "s1") -> FuelStation { 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", FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St",
postcode: "SW1A 1AA", lat: 53.7, lng: -1.8, postcode: "SW1A 1AA", lat: lat, lng: lng,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil) prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
} }
@@ -54,7 +58,21 @@ final class LiveChainTests: XCTestCase {
XCTAssertEqual(LiveChainProvider.lastSource, "github") XCTAssertEqual(LiveChainProvider.lastSource, "github")
} }
func testFullPathFallsBackToRelay() async throws { 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 mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")]) let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay) let chain = LiveChainProvider(mirror: mirror, relay: relay)
@@ -62,35 +80,77 @@ final class LiveChainTests: XCTestCase {
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
XCTAssertEqual(stations.first?.id, "relay") XCTAssertEqual(stations.first?.id, "relay")
XCTAssertEqual(relay.callCount, 1, "relay must serve when the mirror is down") XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
XCTAssertEqual(LiveChainProvider.lastSource, "relay") XCTAssertEqual(LiveChainProvider.lastSource, "relay")
} }
// MARK: Focused path (alert checks): relay first never pull the full // MARK: Focused path (alert checks + widget): GitHub first too
// ~2.8 MB dump over mobile data for a background alert when the relay is up.
func testFocusedPathPrefersRelay() async throws { func testFocusedPathPrefersMirror() async throws {
let mirror = StubProvider(result: [fixtureStation("mirror")]) let mirror = StubProvider(result: [fixtureStation("mirror")])
let relay = StubProvider(result: [fixtureStation("relay")]) let relay = StubProvider(result: [fixtureStation("relay")])
let chain = LiveChainProvider(mirror: mirror, relay: relay) let chain = LiveChainProvider(mirror: mirror, relay: relay)
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(relay.callCount, 1, "focused alert fetch must prefer the light relay call") XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
XCTAssertEqual(mirror.callCount, 0, "mirror full dump must NOT be pulled when the relay is up") XCTAssertEqual(relay.callCount, 0)
XCTAssertEqual(LiveChainProvider.lastSource, "relay") XCTAssertEqual(LiveChainProvider.lastSource, "github")
} }
func testFocusedPathFallsBackToMirror() async throws { func testFocusedPathRelaySkippedWhenDisabled() async {
let mirror = StubProvider(result: [fixtureStation("mirror")]) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(error: FuelProviderError.relayUnavailable) 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 chain = LiveChainProvider(mirror: mirror, relay: relay)
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
XCTAssertEqual(stations.first?.id, "mirror") XCTAssertEqual(stations.first?.id, "relay")
XCTAssertEqual(mirror.callCount, 1) XCTAssertEqual(relay.callCount, 1)
XCTAssertEqual(LiveChainProvider.lastSource, "github") }
// 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 // MARK: Mirror day-cache decision
@@ -103,7 +163,7 @@ final class LiveChainTests: XCTestCase {
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil)) XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
} }
// MARK: Beacon URL // MARK: Beacon
func testBeaconURLCarriesAppAttribution() { func testBeaconURLCarriesAppAttribution() {
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else { guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
@@ -115,4 +175,12 @@ final class LiveChainTests: XCTestCase {
XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github"))) XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github")))
XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022"))) 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")
}
} }
+24 -7
View File
@@ -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
+17
View File
@@ -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
+50 -38
View File
@@ -4,22 +4,25 @@
// PRODUCTION data source: its day files are the raw relay /api/v1/stations // PRODUCTION data source: its day files are the raw relay /api/v1/stations
// envelope, so the shared decode works verbatim. Fetching from // envelope, so the shared decode works verbatim. Fetching from
// raw.githubusercontent.com makes live prices work fully off-LAN App // raw.githubusercontent.com makes live prices work fully off-LAN App
// Review cannot reach the LAN relay and the bundled dump stays as the // Review cannot reach a LAN relay, and consumers never have one to reach
// no-network last resort. // and the bundled dump (app target) is the no-network last resort.
// //
// Chain (LiveChainProvider): // Chain (LiveChainProvider) GitHub first, EVERYWHERE:
// full path (app refresh): GitHub mirror LAN relay bundled dump // GitHub mirror [dev-only LAN relay] (app target: bundled dump in the
// focused path (alert fetch): LAN relay (small radius fetch) GitHub // ContentView catch, widget: cache placeholder)
// mirror (full decode, device filters) dump // The relay is NOT part of the consumer chain at all: it is unreachable from
// The focused path stays relay-first so a background alert check never pulls // any phone outside the developer's LAN, and even attempting it from a
// the ~2.8 MB full dump over mobile data when the relay is reachable. // 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 // Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
// (page views/clones only), so every chain fetch fires an opportunistic // (page views/clones only), so every chain fetch opportunistically pings the
// fire-and-forget X-Client "app" ping at the relay's existing widget-diag // relay's existing widget-diag route (zero relay changes) but ONLY when the
// route (zero relay changes). On-LAN = attribution + cadence; off-LAN the // same dev flag is on, so consumers never make a local-network attempt.
// ping fails in ~2 s and the skip itself is the reachability datum. The // /stats app-hit spikes (dev flag on) = GitHub path failing fallback active.
// relay's /stats app-hit spike doubles as the fallback-outage signal.
import Foundation import Foundation
@@ -66,6 +69,21 @@ struct MirrorFuelProvider: FuelPriceProviding {
return cachedDay == latestDay 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] { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
// 1. Tiny pointer fetch the freshest snapshot day. // 1. Tiny pointer fetch the freshest snapshot day.
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL), guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
@@ -89,16 +107,18 @@ struct MirrorFuelProvider: FuelPriceProviding {
} }
let stations = try FuelPriceProvider.decodeStations(from: data) let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data) Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return stations return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
} }
} }
// MARK: - Live chain provider // MARK: - Live chain provider
/// The live price chain. Full path prefers the GitHub mirror; the focused /// The live price chain GitHub first, everywhere. The LAN relay is NOT part
/// path (alert checks) stays relay-first so a background alert never pulls /// of the consumer chain: consumers can never reach it (and must never even
/// the full dump over mobile data when the relay is up. Whichever leg serves /// attempt it that would prompt for Local Network access on a stranger's
/// is recorded for telemetry + the About section. /// 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 { struct LiveChainProvider: FuelPriceProviding {
var mirror: FuelPriceProviding var mirror: FuelPriceProviding
var relay: FuelPriceProviding var relay: FuelPriceProviding
@@ -114,23 +134,6 @@ struct LiveChainProvider: FuelPriceProviding {
} }
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] { func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
if radiusKM != nil {
// Focused alert fetch: light relay radius call first, mirror fallback.
do {
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = RelayFuelProvider.latestMeta
Self.lastSource = "relay"
FuelBeacon.fire(source: "relay", n: stations.count)
return stations
} catch {
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = MirrorFuelProvider.latestMeta
Self.lastSource = "github"
FuelBeacon.fire(source: "github", n: stations.count)
return stations
}
}
// Full dump: GitHub first (off-LAN + App Review), relay fallback.
do { do {
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM) let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = MirrorFuelProvider.latestMeta Self.latestMeta = MirrorFuelProvider.latestMeta
@@ -138,6 +141,9 @@ struct LiveChainProvider: FuelPriceProviding {
FuelBeacon.fire(source: "github", n: stations.count) FuelBeacon.fire(source: "github", n: stations.count)
return stations return stations
} catch { } 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) let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
Self.latestMeta = RelayFuelProvider.latestMeta Self.latestMeta = RelayFuelProvider.latestMeta
Self.lastSource = "relay" Self.lastSource = "relay"
@@ -150,13 +156,19 @@ struct LiveChainProvider: FuelPriceProviding {
// MARK: - Telemetry beacon // MARK: - Telemetry beacon
/// Fire-and-forget attribution for the LIVE chain (see header note). Never /// Fire-and-forget attribution for the LIVE chain (see header note). Never
/// awaited, never user-visible; a failed ping (off-LAN) is silent and IS the /// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
/// reachability datum. Disabled in unit tests via `isEnabled`. /// 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 { enum FuelBeacon {
static var isEnabled = true static var isEnabled = true
static var shouldFire: Bool {
isEnabled && FuelStore.loadRelayFallbackEnabled()
}
static func fire(source: String, n: Int, timeout: TimeInterval = 2) { static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
guard isEnabled, guard shouldFire,
let url = beaconURL(source: source, n: n) else { return } let url = beaconURL(source: source, n: n) else { return }
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout) var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
request.cachePolicy = .reloadIgnoringLocalCacheData request.cachePolicy = .reloadIgnoringLocalCacheData