Harden App Store build for review

This commit is contained in:
FuelBoard Contributor
2026-10-01 12:19:05 +01:00
parent c7731d2b36
commit 50c543c776
14 changed files with 177 additions and 412 deletions
+13 -15
View File
@@ -123,8 +123,8 @@ correct app/team association, and a matching Sandbox Apple Account.
with the **App Group `group.com.apt.fuelboard`** capability AND the with the **App Group `group.com.apt.fuelboard`** capability AND the
**In-App Purchase** capability enabled (the widget + app-group cache **In-App Purchase** capability enabled (the widget + app-group cache
depend on the group; the tip jar needs IAP). depend on the group; the tip jar needs IAP).
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget 3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared). watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
4. **[ME] Real Distribution signing** — Distribution certificate + App Store 4. **[ME] Real Distribution signing** — Distribution certificate + App Store
provisioning profile (or Xcode automatic signing once the account is in provisioning profile (or Xcode automatic signing once the account is in
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`). Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
@@ -135,21 +135,20 @@ correct app/team association, and a matching Sandbox Apple Account.
## Required tasks — owner [ME] (technical, do after account exists) ## Required tasks — owner [ME] (technical, do after account exists)
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16): - [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
UserDefaults reason `1C8F.1`; app also declares Location (linked, not manifests added: UserDefaults reason `1C8F.1`; app also declares Location
tracking) + Diagnostics (not linked, not tracking — the dev beacon) (linked, not tracking). Diagnostic network beacon removed with LAN relay.
- [ ] Distribution archive + TestFlight upload (needs Apple account) - [ ] Distribution archive + TestFlight upload (needs Apple account)
- [x] **Version bump** → `1.0.1` (build 2), app + widget plists - [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices - [x] **OGL attribution** — Settings → Data source footer now reads "Prices
use the UK Fuel Finder dataset (Open Government Licence) and refresh use the UK Fuel Finder dataset (Open Government Licence) and refresh
up to twice a day." (2.3.1 hygiene) up to twice a day." (2.3.1 hygiene)
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only - [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
explainer + "Browse all UK stations" button (switches to Closest) explainer + "Browse all UK stations" button (switches to Closest)
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on - [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
purpose**: the user's home-testing installs the Release IPA and toggles permission string, relay toggle, or diagnostic network beacon remains in
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping the app/widget/watch binary. Hidden Debug still exists for local notification
About → Version ×5 — invisible to reviewers; no credentials involved). and widget diagnostics only.
Gating it out of Release would break that workflow for zero review gain.
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device - [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** — - [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites, native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
@@ -175,8 +174,7 @@ correct app/team association, and a matching Sandbox Apple Account.
category (Utilities), pricing (Free), availability (UK first?) category (Utilities), pricing (Free), availability (UK first?)
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL - [ ] Description, keywords, support URL, marketing URL, privacy policy URL
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user - [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not for alerts/nearby; no tracking; no ATT prompt needed)
linked, not used for tracking; no ATT prompt needed)
- [ ] Age rating questionnaire (4+; no objectionable content) - [ ] Age rating questionnaire (4+; no objectionable content)
- [ ] Export compliance: HTTPS-only + keychain → standard encryption - [ ] Export compliance: HTTPS-only + keychain → standard encryption
exemption (5A992a), no documentation needed exemption (5A992a), no documentation needed
@@ -214,8 +212,8 @@ correct app/team association, and a matching Sandbox Apple Account.
glance. It is declared in the plist already. glance. It is declared in the plist already.
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia, - **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
nominative use (cf. GasBuddy); low risk, keep sources documented. nominative use (cf. GasBuddy); low risk, keep sources documented.
- **GitHub raw as data source** — public data, no credentials; bundled dump - **GitHub raw as data source** — public HTTPS data, no credentials or Local
covers any regional GitHub unreachability. Network permission; bundled dump covers any regional GitHub unreachability.
## Timeline (best case) ## Timeline (best case)
+7 -10
View File
@@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app.
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
filters/sorts on-device, with a bundled snapshot as the no-network last resort. filters/sorts on-device, with a bundled snapshot as the no-network last resort.
LAN relay is dev-only (hidden `fuelboard.relayFallback` flag). LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source.
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea. Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
Status: TODO / IN PROGRESS / DONE / BLOCKED. Status: TODO / IN PROGRESS / DONE / BLOCKED.
@@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from (`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) → raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
**GitHub FIRST everywhere** (alert checks + widget too — the relay is **GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
unreachable from any consumer phone and attempting it would fire the Local fallback, private IP, Local Network permission, or diagnostic network beacon
Network prompt); LAN relay joins ONLY behind the hidden dev flag ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`) Local Network permission removed entirely — data page is informational, replay
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app on cellular can no longer hang. 107+ tests.
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
-2
View File
@@ -33,8 +33,6 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
<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>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
+10 -10
View File
@@ -524,7 +524,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -590,7 +590,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -654,7 +654,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -719,7 +719,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -817,7 +817,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -842,7 +842,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -866,7 +866,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -891,7 +891,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -914,7 +914,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests; INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
@@ -937,7 +937,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests; INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
-7
View File
@@ -62,9 +62,6 @@ 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
@@ -176,10 +173,6 @@ 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: {
@@ -3,23 +3,11 @@ import XCTest
@testable import FuelBoardShared @testable import FuelBoardShared
#endif #endif
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] → /// P0 live chain: public GitHub mirror first everywhere →
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder). /// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
/// The relay is NOT part of the consumer chain — it only joins behind the /// No LAN relay fallback is compiled into the app binary.
/// hidden dev flag `fuelboard.relayFallback`.
final class LiveChainTests: XCTestCase { 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 // MARK: Fixtures
private final class StubProvider: FuelPriceProviding { private final class StubProvider: FuelPriceProviding {
@@ -45,86 +33,56 @@ final class LiveChainTests: XCTestCase {
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil) prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
} }
// MARK: Full path (app refresh): GitHub first // MARK: Full path (app refresh): GitHub mirror only
func testFullPathPrefersMirror() async throws { func testFullPathUsesMirror() async throws {
let mirror = StubProvider(result: [fixtureStation()]) let mirror = StubProvider(result: [fixtureStation()])
let relay = StubProvider(error: FuelProviderError.relayUnavailable) let chain = LiveChainProvider(mirror: mirror)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
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.count, 1) XCTAssertEqual(stations.count, 1)
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first") XCTAssertEqual(mirror.callCount, 1, "full path must use the public GitHub mirror")
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
XCTAssertEqual(LiveChainProvider.lastSource, "github") XCTAssertEqual(LiveChainProvider.lastSource, "github")
} }
func testFullPathRelaySkippedWhenDisabled() async { func testFullPathPropagatesMirrorFailure() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
do { do {
_ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil) _ = 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") XCTFail("chain must throw when the public mirror fails; callers decide cache/bundled fallback")
} catch { } catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") XCTAssertEqual(mirror.callCount, 1)
} }
} }
func testFullPathRelayFallbackWhenDevFlagOn() async throws { // MARK: Focused path (alert checks + widget): GitHub mirror only
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) func testFocusedPathUsesMirror() async throws {
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 mirror = StubProvider(result: [fixtureStation("mirror")])
let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror)
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", "focused path must prefer the mirror — the relay is unreachable off-LAN") XCTAssertEqual(stations.first?.id, "mirror", "focused path must use the public GitHub mirror")
XCTAssertEqual(relay.callCount, 0) XCTAssertEqual(mirror.callCount, 1)
XCTAssertEqual(LiveChainProvider.lastSource, "github") XCTAssertEqual(LiveChainProvider.lastSource, "github")
} }
func testFocusedPathRelaySkippedWhenDisabled() async { func testFocusedPathPropagatesMirrorFailure() async {
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable) let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
let relay = StubProvider(result: [fixtureStation("relay")]) let chain = LiveChainProvider(mirror: mirror)
let chain = LiveChainProvider(mirror: mirror, relay: relay)
do { do {
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5) _ = 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") XCTFail("focused path must throw when the public mirror fails")
} catch { } catch {
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay") XCTAssertEqual(mirror.callCount, 1)
} }
} }
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) // MARK: Mirror focused projection (replaces the relay's server-side radius)
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() { func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
@@ -165,24 +123,4 @@ final class LiveChainTests: XCTestCase {
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil)) 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")
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+19 -85
View File
@@ -82,8 +82,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
/// Diagnostics beacon tag: empty → derive from the intent type name. A /// Diagnostics beacon tag: empty → derive from the intent type name. A
/// widget kind that shares an intent (the A/B test small widget reuses the /// widget kind that shares an intent (the A/B test small widget reuses the
/// medium intent) passes an explicit tag so its beacons stay /// medium intent) passes an explicit tag so its keychain diagnostic slots
/// distinguishable in the relay log and keychain slots. /// stay distinguishable.
var beaconTag: String = "" var beaconTag: String = ""
func placeholder(in context: Context) -> FuelPriceEntry { func placeholder(in context: Context) -> FuelPriceEntry {
@@ -122,10 +122,9 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
} }
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain /// Fire-and-forget diagnostics beacon: writes the entry state to keychain
/// (app-readable, one slot per intent type) and GETs the relay so its /// (app-readable, one slot per intent type) and records what a widget
/// access log records that a widget timeline actually ran in the /// timeline produced for Settings → Widget Diagnostics. Deliberately
/// extension and what it produced. Deliberately outside the timeline /// outside the timeline result — can never affect rendering.
/// result — can never affect rendering.
private func writeDiagBeacon(entry: FuelPriceEntry) { private func writeDiagBeacon(entry: FuelPriceEntry) {
let d = entry.data let d = entry.data
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
@@ -137,29 +136,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)} "station":"\(first?.name ?? "")","price":\(first?.prices[d.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 // Local app-group diagnostics only. No network beacon is sent: the
// must never make a local-network attempt. The local app-group diag // App Store build must not contain a private LAN relay fallback.
// write above stays — Settings → Widget Diagnostics reads that.
guard FuelStore.loadRelayFallbackEnabled() else { return }
guard var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
resolvingAgainstBaseURL: false
) else { return }
components.queryItems = [
URLQueryItem(name: "intent", value: intentType),
URLQueryItem(name: "source", value: d.locationSource),
URLQueryItem(name: "n", value: String(d.stations.count)),
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
URLQueryItem(name: "sort", value: d.sort.rawValue),
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
URLQueryItem(name: "station", value: first?.name ?? ""),
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
]
guard let url = components.url else { return }
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
Task {
_ = try? await URLSession.shared.data(for: request)
}
} }
private func makeEntryCore(configuration: Configuration) async -> FuelPriceEntry { private func makeEntryCore(configuration: Configuration) async -> FuelPriceEntry {
@@ -201,7 +179,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
// Favourites tab drag-to-reorder). Not radius-bound — a favourite // Favourites tab drag-to-reorder). Not radius-bound — a favourite
// in Edinburgh shows on a widget in London. Prices come from the // in Edinburgh shows on a widget in London. Prices come from the
// keychain snapshot (the app refreshes favourite prices into // keychain snapshot (the app refreshes favourite prices into
// keychain after every fetch), or fresher from a focused relay // keychain after every fetch), or fresher from a focused mirror
// fetch when a favourite happens to be in range. The small face // fetch when a favourite happens to be in range. The small face
// renders the FIRST of this list — the user's top favourite for // renders the FIRST of this list — the user's top favourite for
// the chosen fuel. (The old per-widget pinned-favourite picker // the chosen fuel. (The old per-widget pinned-favourite picker
@@ -223,7 +201,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
ordered = refreshed ordered = refreshed
} else { } else {
// 3a) Load stations. The widget prefers its OWN focused fetch from the // 3a) Load stations. The widget prefers its OWN focused fetch from the
// relay so it shows real prices even when the app-group cache isn't // public mirror so it shows real prices even when the app-group cache isn't
// shared (SideStore free accounts don't provision the group, which // shared (SideStore free accounts don't provision the group, which
// previously left the widget stuck on sample data). Falls back to the // previously left the widget stuck on sample data). Falls back to the
// shared cache, then to samples. // shared cache, then to samples.
@@ -289,65 +267,21 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
) )
} }
/// One focused fetch around the widget's location. GitHub mirror FIRST — /// One focused fetch around the widget's location. Public HTTPS mirror only;
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a /// no private LAN fallback is attempted. Returns nil on any failure so
/// local decode rather than a 2.8 MB download. The LAN relay only joins /// callers fall back to cache/placeholder.
/// 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( try? await MirrorFuelProvider().fetchStations(
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) { near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM)
return stations
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "lat", value: String(location.lat)),
URLQueryItem(name: "lng", value: String(location.lng)),
URLQueryItem(name: "radius", value: String(radiusKM)),
URLQueryItem(name: "limit", value: "500"),
]
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? FuelPriceProvider.decodeStations(from: data)
} catch {
return nil
}
} }
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has /// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
/// no location fix so the face shows real stations instead of sample /// no location fix so the face shows real stations instead of sample data.
/// data. GitHub mirror first (full dump + local fuel filter), relay only /// Public HTTPS mirror only; same nil-on-failure 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( guard let stations = try? await MirrorFuelProvider().fetchStations(
near: nil, lng: nil, fuel: fuel, radiusKM: nil) { near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil }
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit)) return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
}
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
var components = URLComponents(
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "fuel", value: fuel.rawValue),
URLQueryItem(name: "limit", value: String(limit)),
]
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? FuelPriceProvider.decodeStations(from: data)
} catch {
return nil
}
} }
} }
+17 -19
View File
@@ -4,24 +4,21 @@ Audit date: 2026-08-11 · Scope: app, widget, relay, build pipeline, git history
## Summary ## Summary
No critical issues. No secrets in source, git history, or binaries. One No critical issues. No secrets in source, git history, or binaries. The prior
architecture note (relay IP baked into the app) is scheduled for removal by LAN relay/private-IP app path has been removed from the app binary; production
the govuk-API switch (BACKLOG P0). Findings below are ordered by severity. data access is public HTTPS GitHub mirror plus bundled fallback. Findings below
are ordered by severity.
## Findings ## Findings
### 1. LAN IP baked into app binary — MEDIUM (planned fix) ### 1. LAN IP baked into app binary — RESOLVED
- `Shared/FuelPriceProvider.swift:52` — `baseURL = URL(string: "http://192.168.1.131:8788")!` - The app no longer contains `RelayFuelProvider`, `fuelboard.relayFallback`,
- Confirmed present in the built app binary (`strings`). `NSLocalNetworkUsageDescription`, or a private `192.168.*` live-data endpoint.
- **Why it works**: ATS exempts raw numeric IP addresses (documented Apple - Live data uses the public HTTPS GitHub mirror; the app falls back to bundled
behaviour), so no `NSAllowsArbitraryLoads` exception exists in the plist — data/cache when the mirror is unavailable.
the plist is clean. - A built-binary `strings` scan should remain part of every release gate to
- **Risk**: the relay is a private LAN host; the app is unusable off the ensure no private LAN endpoint is reintroduced.
user's network, and the URL is effectively a hardcoded endpoint.
- **Fix**: the govuk API switch (BACKLOG P0) replaces this with the HTTPS
gov.uk endpoint via the relay. For any future public build, the app should
talk to an HTTPS domain, never a raw IP.
### 2. No secrets in source / git / binary — PASS ### 2. No secrets in source / git / binary — PASS
@@ -31,8 +28,9 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
- Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only - Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only
(`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc` (`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc`
untracked; `.env.example` ships empty placeholders. untracked; `.env.example` ships empty placeholders.
- Binary scan: only the relay IP, `maps.apple.com`, and `group.com.apt.fuelboard` - Binary scan should show no private LAN data endpoint; expected network strings
strings found. No credentials. include public GitHub raw URLs, `maps.apple.com`, and `group.com.apt.fuelboard`.
No credentials.
### 3. Price data hardening — PASS (three layers) ### 3. Price data hardening — PASS (three layers)
@@ -53,11 +51,11 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
- `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without - `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without
unlock. Correct for background widget refresh. unlock. Correct for background widget refresh.
### 5. Transport security — PASS for LAN dev, MUST CHANGE for public ### 5. Transport security — PASS
- No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep). - No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep).
- Cleartext HTTP only to the LAN relay IP (ATS-exempt). Once the relay moves - No cleartext LAN relay endpoint ships in the app binary. Live network data is
behind an HTTPS domain (govuk switch), no ATS changes are needed. fetched over HTTPS from the public GitHub mirror.
### 6. Privacy / permissions — review for App Store ### 6. Privacy / permissions — review for App Store
+3 -3
View File
@@ -1,7 +1,7 @@
// FuelHistory.swift — price-history store for the Favourites Trends graph. // FuelHistory.swift — price-history store for the Favourites Trends graph.
// //
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the // Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps. // ingestion relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
// The store keeps a favourites-only day cache in the app group so the chart // The store keeps a favourites-only day cache in the app group so the chart
// works offline once a day has been seen, and only fetches the days it is // works offline once a day has been seen, and only fetches the days it is
// missing. Foundation-only so the unit-test target can compile it on macOS. // missing. Foundation-only so the unit-test target can compile it on macOS.
@@ -66,8 +66,8 @@ enum FuelHistoryError: LocalizedError {
// MARK: - Store // MARK: - Store
enum FuelHistoryStore { enum FuelHistoryStore {
/// GitHub raw mirror base — the app reads history directly from here; /// GitHub raw mirror base — the app reads history and live data directly from here.
/// the LAN relay remains the live-data fallback. /// No private LAN relay fallback ships in the app binary.
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")! static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
static let historyCacheKey = "fuelboard.historyCache" static let historyCacheKey = "fuelboard.historyCache"
static let maxCachedDays = 90 static let maxCachedDays = 90
+23 -75
View File
@@ -19,14 +19,15 @@ protocol FuelPriceProviding {
} }
enum FuelPriceProvider { enum FuelPriceProvider {
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN /// Default: the live chain — public GitHub mirror (primary, off-LAN) →
/// relay (fallback) → bundled dump (last resort in the app target). /// bundled dump/cache fallback in callers. No private LAN fallback ships in
/// the app binary.
static let active: FuelPriceProviding = LiveChainProvider() static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a relay payload into stations, applying the defensive price /// Decodes a mirror payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard. /// band. Internal so the unit-test target can exercise the guard.
static func decodeStations(from data: Data) throws -> [FuelStation] { static func decodeStations(from data: Data) throws -> [FuelStation] {
let payload = try JSONDecoder().decode(RelayResponse.self, from: data) let payload = try JSONDecoder().decode(MirrorResponse.self, from: data)
return payload.stations.map { relay in return payload.stations.map { relay in
FuelStation( FuelStation(
id: relay.id ?? relay.name ?? UUID().uuidString, id: relay.id ?? relay.name ?? UUID().uuidString,
@@ -42,13 +43,13 @@ enum FuelPriceProvider {
} }
} }
/// Price sanity band (pence/litre) — anything outside is relay regression /// Price sanity band (pence/litre) — anything outside is mirror regression
/// garbage and must never reach calculations (shared by the live decode /// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder). /// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500 static let priceBand: ClosedRange<Double> = 50...500
/// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive /// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive
/// band guard. Shared by the relay decode and the history mirror parser so /// band guard. Shared by the mirror decode and the history mirror parser so
/// both surfaces apply identical sanitisation. /// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] { static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:] var result: [FuelType: Double] = [:]
@@ -64,12 +65,12 @@ enum FuelPriceProvider {
return result return result
} }
/// Decodes the relay envelope metadata (source, dataset update time) — the /// Decodes mirror envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is /// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive /// live and how fresh the GOV.UK data itself is. The fields are additive
/// on the relay, so older relays simply return nil values. /// on the mirror, so older snapshots simply return nil values.
static func decodeRelayMeta(from data: Data) -> RelayMeta? { static func decodeRelayMeta(from data: Data) -> RelayMeta? {
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else { guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else {
return nil return nil
} }
return RelayMeta( return RelayMeta(
@@ -80,64 +81,11 @@ enum FuelPriceProvider {
} }
} }
// MARK: - Relay provider (default) // MARK: - Mirror payload envelope
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK private struct MirrorResponse: Codable {
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
/// filters/sorts server-side. No credentials in the app.
struct RelayFuelProvider: FuelPriceProviding {
var baseURL = URL(string: "http://192.168.1.131:8789")!
/// Envelope metadata from the most recent successful relay fetch —
/// consumed by Settings → About (source api/csv, station count, GOV.UK
/// dataset update time). Written by fetchStations, read by the view.
static var latestMeta: RelayMeta?
/// Admin telemetry: the relay tags inbound connections by client kind
/// (app vs widget) so the developer can monitor the poll model. Siri
/// never fetches (cached-data-first). Purely observational — the relay
/// ignores the header for responses.
static let clientTagHeader = "X-Client"
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
request.setValue(client, forHTTPHeaderField: clientTagHeader)
return request
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
if let lat, let lng {
query.append(URLQueryItem(name: "lat", value: String(lat)))
query.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let radiusKM {
// Focused fetch (alert path) — small response, server-side radius.
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
query.append(URLQueryItem(name: "limit", value: "500"))
} else {
// Full-UK dump: NO radius — the device filters locally
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
query.append(URLQueryItem(name: "limit", value: "10000"))
}
components.queryItems = query
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable
}
let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return stations
}
}
private struct RelayResponse: Codable {
/// Envelope metadata surfaced in Settings → About so the user can see /// Envelope metadata surfaced in Settings → About so the user can see
/// which relay source is live and how fresh the GOV.UK data is. /// which mirror source is live and how fresh the GOV.UK data is.
let source: String? let source: String?
let stationsCount: Int? let stationsCount: Int?
let dataUpdated: String? let dataUpdated: String?
@@ -148,7 +96,7 @@ private struct RelayResponse: Codable {
case dataUpdated = "data_updated" case dataUpdated = "data_updated"
} }
struct RelayStation: Codable { struct MirrorStation: Codable {
let id: String? let id: String?
let name: String? let name: String?
let brand: String? let brand: String?
@@ -159,24 +107,24 @@ private struct RelayResponse: Codable {
let price: Double? let price: Double?
let prices: [String: Double]? let prices: [String: Double]?
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the /// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard: /// station sells — one fetch populates every fuel tab. Defensive guard:
/// any grade outside the 50–500 pence/litre band is dropped, so a relay /// any grade outside the 50–500 pence/litre band is dropped, so a mirror
/// regression (e.g. the band being removed server-side) can't re-poison /// regression (e.g. the band being removed upstream) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage. /// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] { var allPrices: [FuelType: Double] {
var result = FuelPriceProvider.mapGrades(prices ?? [:]) var result = FuelPriceProvider.mapGrades(prices ?? [:])
// Backwards-compat: relay versions without `prices` still send `price`. // Backwards-compat: mirror versions without `prices` still send `price`.
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) { if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price result[.e10] = price
} }
return result return result
} }
} }
let stations: [RelayStation] let stations: [MirrorStation]
} }
/// Envelope metadata from the relay response — which data source is live /// Envelope metadata from the mirror response — which data source is live
/// (api/csv) and the freshest price-update timestamp the GOV.UK server /// (api/csv) and the freshest price-update timestamp the GOV.UK server
/// itself reports for the dataset. Displayed in Settings → About. /// itself reports for the dataset. Displayed in Settings → About.
struct RelayMeta: Codable, Equatable { struct RelayMeta: Codable, Equatable {
@@ -189,7 +137,7 @@ struct RelayMeta: Codable, Equatable {
/// Representative stations spread across ENGLAND (one cluster per region) so /// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country — it is NOT tied to one town. /// the fallback works anywhere in the country — it is NOT tied to one town.
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API). /// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre. /// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding { struct SampleFuelProvider: 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] {
@@ -302,7 +250,7 @@ enum FuelProviderError: LocalizedError {
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 live data unavailable — showing cached/sample data."
case .mirrorUnavailable: case .mirrorUnavailable:
return "GitHub price mirror unreachable." return "GitHub price mirror unreachable."
} }
-17
View File
@@ -407,7 +407,6 @@ struct FuelStore {
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
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)
@@ -711,22 +710,6 @@ 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
+19 -87
View File
@@ -1,28 +1,16 @@
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain). // MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
// //
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the // The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay)
// PRODUCTION data source: its day files are the raw relay /api/v1/stations // is the PRODUCTION data source: its day files use the same /api/v1/stations
// envelope, so the shared decode works verbatim. Fetching from // envelope, so the shared decoder 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
// Review cannot reach a LAN relay, and consumers never have one to reach — // cannot reach a private LAN relay, and consumers never have one to reach —
// and the bundled dump (app target) is the no-network last resort. // with the bundled dump (app target) as the no-network last resort.
// //
// Chain (LiveChainProvider) — GitHub first, EVERYWHERE: // Chain (LiveChainProvider) — public HTTPS only:
// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the // GitHub mirror → (app target: bundled dump in the ContentView catch,
// ContentView catch, widget: cache → placeholder) // widget: cache → placeholder)
// The relay is NOT part of the consumer chain at all: it is unreachable from // No local-network fallback or private IP is compiled into the app binary.
// 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 import Foundation
@@ -126,80 +114,24 @@ struct MirrorFuelProvider: FuelPriceProviding {
// MARK: - Live chain provider // MARK: - Live chain provider
/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part /// The live price chain — public GitHub mirror only. No private LAN fallback is
/// of the consumer chain: consumers can never reach it (and must never even /// compiled into the app binary, so App Store/TestFlight builds cannot trigger
/// attempt it — that would prompt for Local Network access on a stranger's /// Local Network permission or leak a private IP.
/// 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
/// Which leg served the last fetch — Settings → About metadata + beacon. /// Metadata surfaced in Settings → About.
static var latestMeta: RelayMeta? static var latestMeta: RelayMeta?
static var lastSource: String? static var lastSource: String?
init(mirror: FuelPriceProviding = MirrorFuelProvider(), init(mirror: FuelPriceProviding = MirrorFuelProvider()) {
relay: FuelPriceProviding = RelayFuelProvider()) {
self.mirror = mirror self.mirror = mirror
self.relay = relay
} }
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] {
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 Self.lastSource = "github"
Self.lastSource = "github" return stations
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
} }
} }