Harden App Store build for review
This commit is contained in:
+13
-15
@@ -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
|
||||
**In-App Purchase** capability enabled (the widget + app-group cache
|
||||
depend on the group; the tip jar needs IAP).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
|
||||
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
|
||||
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
|
||||
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
||||
provisioning profile (or Xcode automatic signing once the account is in
|
||||
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)
|
||||
|
||||
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
|
||||
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
|
||||
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
|
||||
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
|
||||
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
|
||||
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
|
||||
- [ ] 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
|
||||
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
||||
up to twice a day." (2.3.1 hygiene)
|
||||
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
||||
explainer + "Browse all UK stations" button (switches to Closest)
|
||||
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
|
||||
purpose**: the user's home-testing installs the Release IPA and toggles
|
||||
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
|
||||
About → Version ×5 — invisible to reviewers; no credentials involved).
|
||||
Gating it out of Release would break that workflow for zero review gain.
|
||||
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
|
||||
permission string, relay toggle, or diagnostic network beacon remains in
|
||||
the app/widget/watch binary. Hidden Debug still exists for local notification
|
||||
and widget diagnostics only.
|
||||
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
||||
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
||||
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?)
|
||||
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
||||
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
||||
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
|
||||
linked, not used for tracking; no ATT prompt needed)
|
||||
for alerts/nearby; no tracking; no ATT prompt needed)
|
||||
- [ ] Age rating questionnaire (4+; no objectionable content)
|
||||
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
||||
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.
|
||||
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
||||
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
||||
- **GitHub raw as data source** — public data, no credentials; bundled dump
|
||||
covers any regional GitHub unreachability.
|
||||
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
|
||||
Network permission; bundled dump covers any regional GitHub unreachability.
|
||||
|
||||
## Timeline (best case)
|
||||
|
||||
|
||||
+7
-10
@@ -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
|
||||
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.
|
||||
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.
|
||||
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
|
||||
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
||||
unreachable from any consumer phone and attempting it would fire the Local
|
||||
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
||||
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
||||
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
||||
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
||||
ping fires only with the dev flag on. **Local Network permission removed from
|
||||
onboarding entirely** — data page is informational, replay on cellular can no
|
||||
longer hang. 95 tests.
|
||||
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
|
||||
fallback, private IP, Local Network permission, or diagnostic network beacon
|
||||
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
|
||||
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
|
||||
Local Network permission removed entirely — data page is informational, replay
|
||||
on cellular can no longer hang. 107+ tests.
|
||||
- [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
|
||||
live in relay `.env` only, never in the IPA
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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>
|
||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
|
||||
@@ -524,7 +524,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -590,7 +590,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -654,7 +654,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -719,7 +719,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -817,7 +817,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -842,7 +842,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
@@ -866,7 +866,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -891,7 +891,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
@@ -914,7 +914,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
@@ -937,7 +937,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
|
||||
@@ -62,9 +62,6 @@ struct SettingsView: View {
|
||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||
@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 lastVersionTap = Date.distantPast
|
||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||
@@ -176,10 +173,6 @@ struct SettingsView: View {
|
||||
|
||||
if debugMode {
|
||||
Section {
|
||||
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
|
||||
.onChange(of: relayFallbackEnabled) { _, enabled in
|
||||
FuelStore.saveRelayFallbackEnabled(enabled)
|
||||
}
|
||||
Button {
|
||||
sendTestAlert()
|
||||
} label: {
|
||||
|
||||
@@ -3,23 +3,11 @@ import XCTest
|
||||
@testable import FuelBoardShared
|
||||
#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).
|
||||
/// The relay is NOT part of the consumer chain — it only joins behind the
|
||||
/// hidden dev flag `fuelboard.relayFallback`.
|
||||
/// No LAN relay fallback is compiled into the app binary.
|
||||
final class LiveChainTests: XCTestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
FuelBeacon.isEnabled = false // chain tests must not fire real pings
|
||||
FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
FuelBeacon.isEnabled = true
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: Fixtures
|
||||
|
||||
private final class StubProvider: FuelPriceProviding {
|
||||
@@ -45,86 +33,56 @@ final class LiveChainTests: XCTestCase {
|
||||
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 relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
let chain = LiveChainProvider(mirror: mirror)
|
||||
|
||||
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||
|
||||
XCTAssertEqual(stations.count, 1)
|
||||
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
|
||||
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
|
||||
XCTAssertEqual(mirror.callCount, 1, "full path must use the public GitHub mirror")
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||
}
|
||||
|
||||
func testFullPathRelaySkippedWhenDisabled() async {
|
||||
func testFullPathPropagatesMirrorFailure() async {
|
||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
let chain = LiveChainProvider(mirror: mirror)
|
||||
|
||||
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")
|
||||
XCTFail("chain must throw when the public mirror fails; callers decide cache/bundled fallback")
|
||||
} catch {
|
||||
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
|
||||
XCTAssertEqual(mirror.callCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testFullPathRelayFallbackWhenDevFlagOn() async throws {
|
||||
FuelStore.saveRelayFallbackEnabled(true)
|
||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
// MARK: Focused path (alert checks + widget): GitHub mirror only
|
||||
|
||||
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||
|
||||
XCTAssertEqual(stations.first?.id, "relay")
|
||||
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||
}
|
||||
|
||||
// MARK: Focused path (alert checks + widget): GitHub first too
|
||||
|
||||
func testFocusedPathPrefersMirror() async throws {
|
||||
func testFocusedPathUsesMirror() async throws {
|
||||
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
let chain = LiveChainProvider(mirror: mirror)
|
||||
|
||||
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||
|
||||
XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
|
||||
XCTAssertEqual(relay.callCount, 0)
|
||||
XCTAssertEqual(stations.first?.id, "mirror", "focused path must use the public GitHub mirror")
|
||||
XCTAssertEqual(mirror.callCount, 1)
|
||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||
}
|
||||
|
||||
func testFocusedPathRelaySkippedWhenDisabled() async {
|
||||
func testFocusedPathPropagatesMirrorFailure() async {
|
||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||
let chain = LiveChainProvider(mirror: mirror)
|
||||
|
||||
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")
|
||||
XCTFail("focused path must throw when the public mirror fails")
|
||||
} 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)
|
||||
|
||||
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
|
||||
@@ -165,24 +123,4 @@ final class LiveChainTests: XCTestCase {
|
||||
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>
|
||||
@@ -82,8 +82,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
|
||||
/// 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
|
||||
/// medium intent) passes an explicit tag so its beacons stay
|
||||
/// distinguishable in the relay log and keychain slots.
|
||||
/// medium intent) passes an explicit tag so its keychain diagnostic slots
|
||||
/// stay distinguishable.
|
||||
var beaconTag: String = ""
|
||||
|
||||
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
|
||||
/// (app-readable, one slot per intent type) and GETs the relay so its
|
||||
/// access log records that a widget timeline actually ran in the
|
||||
/// extension and what it produced. Deliberately outside the timeline
|
||||
/// result — can never affect rendering.
|
||||
/// (app-readable, one slot per intent type) and records what a widget
|
||||
/// timeline produced for Settings → Widget Diagnostics. Deliberately
|
||||
/// outside the timeline result — can never affect rendering.
|
||||
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
||||
let d = entry.data
|
||||
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)}
|
||||
"""
|
||||
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(
|
||||
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)
|
||||
}
|
||||
// Local app-group diagnostics only. No network beacon is sent: the
|
||||
// App Store build must not contain a private LAN relay fallback.
|
||||
}
|
||||
|
||||
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
|
||||
// in Edinburgh shows on a widget in London. Prices come from the
|
||||
// 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
|
||||
// renders the FIRST of this list — the user's top favourite for
|
||||
// the chosen fuel. (The old per-widget pinned-favourite picker
|
||||
@@ -223,7 +201,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
ordered = refreshed
|
||||
} else {
|
||||
// 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
|
||||
// previously left the widget stuck on sample data). Falls back to the
|
||||
// shared cache, then to samples.
|
||||
@@ -289,66 +267,22 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
||||
)
|
||||
}
|
||||
|
||||
/// One focused fetch around the widget's location. GitHub mirror FIRST —
|
||||
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
|
||||
/// 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.
|
||||
/// One focused fetch around the widget's location. Public HTTPS mirror only;
|
||||
/// no private LAN fallback is attempted. 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]? {
|
||||
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(
|
||||
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
|
||||
}
|
||||
try? await MirrorFuelProvider().fetchStations(
|
||||
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// data. GitHub mirror first (full dump + local fuel filter), relay only
|
||||
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
|
||||
/// no location fix so the face shows real stations instead of sample data.
|
||||
/// Public HTTPS mirror only; same nil-on-failure semantics as fetchFocused.
|
||||
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) {
|
||||
guard let stations = try? await MirrorFuelProvider().fetchStations(
|
||||
near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FuelPriceWidgetView: View {
|
||||
|
||||
+17
-19
@@ -4,24 +4,21 @@ Audit date: 2026-08-11 · Scope: app, widget, relay, build pipeline, git history
|
||||
|
||||
## Summary
|
||||
|
||||
No critical issues. No secrets in source, git history, or binaries. One
|
||||
architecture note (relay IP baked into the app) is scheduled for removal by
|
||||
the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
|
||||
No critical issues. No secrets in source, git history, or binaries. The prior
|
||||
LAN relay/private-IP app path has been removed from the app binary; production
|
||||
data access is public HTTPS GitHub mirror plus bundled fallback. Findings below
|
||||
are ordered by severity.
|
||||
|
||||
## 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")!`
|
||||
- Confirmed present in the built app binary (`strings`).
|
||||
- **Why it works**: ATS exempts raw numeric IP addresses (documented Apple
|
||||
behaviour), so no `NSAllowsArbitraryLoads` exception exists in the plist —
|
||||
the plist is clean.
|
||||
- **Risk**: the relay is a private LAN host; the app is unusable off the
|
||||
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.
|
||||
- The app no longer contains `RelayFuelProvider`, `fuelboard.relayFallback`,
|
||||
`NSLocalNetworkUsageDescription`, or a private `192.168.*` live-data endpoint.
|
||||
- Live data uses the public HTTPS GitHub mirror; the app falls back to bundled
|
||||
data/cache when the mirror is unavailable.
|
||||
- A built-binary `strings` scan should remain part of every release gate to
|
||||
ensure no private LAN endpoint is reintroduced.
|
||||
|
||||
### 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
|
||||
(`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc`
|
||||
untracked; `.env.example` ships empty placeholders.
|
||||
- Binary scan: only the relay IP, `maps.apple.com`, and `group.com.apt.fuelboard`
|
||||
strings found. No credentials.
|
||||
- Binary scan should show no private LAN data endpoint; expected network strings
|
||||
include public GitHub raw URLs, `maps.apple.com`, and `group.com.apt.fuelboard`.
|
||||
No credentials.
|
||||
|
||||
### 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
|
||||
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).
|
||||
- Cleartext HTTP only to the LAN relay IP (ATS-exempt). Once the relay moves
|
||||
behind an HTTPS domain (govuk switch), no ATS changes are needed.
|
||||
- No cleartext LAN relay endpoint ships in the app binary. Live network data is
|
||||
fetched over HTTPS from the public GitHub mirror.
|
||||
|
||||
### 6. Privacy / permissions — review for App Store
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FuelHistory.swift — price-history store for the Favourites Trends graph.
|
||||
//
|
||||
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
|
||||
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
|
||||
// ingestion relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
|
||||
// The store keeps a favourites-only day cache in the app group so the chart
|
||||
// works offline once a day has been seen, and only fetches the days it is
|
||||
// missing. Foundation-only so the unit-test target can compile it on macOS.
|
||||
@@ -66,8 +66,8 @@ enum FuelHistoryError: LocalizedError {
|
||||
// MARK: - Store
|
||||
|
||||
enum FuelHistoryStore {
|
||||
/// GitHub raw mirror base — the app reads history directly from here;
|
||||
/// the LAN relay remains the live-data fallback.
|
||||
/// GitHub raw mirror base — the app reads history and live data directly from here.
|
||||
/// No private LAN relay fallback ships in the app binary.
|
||||
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
|
||||
static let historyCacheKey = "fuelboard.historyCache"
|
||||
static let maxCachedDays = 90
|
||||
|
||||
@@ -19,14 +19,15 @@ protocol FuelPriceProviding {
|
||||
}
|
||||
|
||||
enum FuelPriceProvider {
|
||||
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
|
||||
/// relay (fallback) → bundled dump (last resort in the app target).
|
||||
/// Default: the live chain — public GitHub mirror (primary, off-LAN) →
|
||||
/// bundled dump/cache fallback in callers. No private LAN fallback ships in
|
||||
/// the app binary.
|
||||
static let active: FuelPriceProviding = LiveChainProvider()
|
||||
|
||||
/// Decodes a relay payload into stations, applying the defensive price
|
||||
/// Decodes a mirror payload into stations, applying the defensive price
|
||||
/// band. Internal so the unit-test target can exercise the guard.
|
||||
static func decodeStations(from data: Data) throws -> [FuelStation] {
|
||||
let payload = try JSONDecoder().decode(RelayResponse.self, from: data)
|
||||
let payload = try JSONDecoder().decode(MirrorResponse.self, from: data)
|
||||
return payload.stations.map { relay in
|
||||
FuelStation(
|
||||
id: relay.id ?? relay.name ?? UUID().uuidString,
|
||||
@@ -42,13 +43,13 @@ enum FuelPriceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Price sanity band (pence/litre) — anything outside is relay regression
|
||||
/// Price sanity band (pence/litre) — anything outside is mirror regression
|
||||
/// garbage and must never reach calculations (shared by the live decode
|
||||
/// and the history mirror decoder).
|
||||
static let priceBand: ClosedRange<Double> = 50...500
|
||||
|
||||
/// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive
|
||||
/// band guard. Shared by the relay decode and the history mirror parser so
|
||||
/// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive
|
||||
/// band guard. Shared by the mirror decode and the history mirror parser so
|
||||
/// both surfaces apply identical sanitisation.
|
||||
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
|
||||
var result: [FuelType: Double] = [:]
|
||||
@@ -64,12 +65,12 @@ enum FuelPriceProvider {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decodes the relay envelope metadata (source, dataset update time) — the
|
||||
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
||||
/// About section shows these so the user can see which data source is
|
||||
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
||||
/// on the relay, so older relays simply return nil values.
|
||||
/// on the mirror, so older snapshots simply return nil values.
|
||||
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
|
||||
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else {
|
||||
guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return RelayMeta(
|
||||
@@ -80,64 +81,11 @@ enum FuelPriceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Relay provider (default)
|
||||
// MARK: - Mirror payload envelope
|
||||
|
||||
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK
|
||||
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
|
||||
/// filters/sorts server-side. No credentials in the app.
|
||||
struct RelayFuelProvider: FuelPriceProviding {
|
||||
var baseURL = URL(string: "http://192.168.1.131:8789")!
|
||||
|
||||
/// Envelope metadata from the most recent successful relay fetch —
|
||||
/// consumed by Settings → About (source api/csv, station count, GOV.UK
|
||||
/// dataset update time). Written by fetchStations, read by the view.
|
||||
static var latestMeta: RelayMeta?
|
||||
|
||||
/// Admin telemetry: the relay tags inbound connections by client kind
|
||||
/// (app vs widget) so the developer can monitor the poll model. Siri
|
||||
/// never fetches (cached-data-first). Purely observational — the relay
|
||||
/// ignores the header for responses.
|
||||
static let clientTagHeader = "X-Client"
|
||||
|
||||
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = timeout
|
||||
request.setValue(client, forHTTPHeaderField: clientTagHeader)
|
||||
return request
|
||||
}
|
||||
|
||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
|
||||
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
|
||||
if let lat, let lng {
|
||||
query.append(URLQueryItem(name: "lat", value: String(lat)))
|
||||
query.append(URLQueryItem(name: "lng", value: String(lng)))
|
||||
}
|
||||
if let radiusKM {
|
||||
// Focused fetch (alert path) — small response, server-side radius.
|
||||
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
|
||||
query.append(URLQueryItem(name: "limit", value: "500"))
|
||||
} else {
|
||||
// Full-UK dump: NO radius — the device filters locally
|
||||
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
|
||||
query.append(URLQueryItem(name: "limit", value: "10000"))
|
||||
}
|
||||
components.queryItems = query
|
||||
|
||||
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw FuelProviderError.relayUnavailable
|
||||
}
|
||||
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||
return stations
|
||||
}
|
||||
}
|
||||
|
||||
private struct RelayResponse: Codable {
|
||||
private struct MirrorResponse: Codable {
|
||||
/// Envelope metadata surfaced in Settings → About so the user can see
|
||||
/// which relay source is live and how fresh the GOV.UK data is.
|
||||
/// which mirror source is live and how fresh the GOV.UK data is.
|
||||
let source: String?
|
||||
let stationsCount: Int?
|
||||
let dataUpdated: String?
|
||||
@@ -148,7 +96,7 @@ private struct RelayResponse: Codable {
|
||||
case dataUpdated = "data_updated"
|
||||
}
|
||||
|
||||
struct RelayStation: Codable {
|
||||
struct MirrorStation: Codable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let brand: String?
|
||||
@@ -159,24 +107,24 @@ private struct RelayResponse: Codable {
|
||||
let price: Double?
|
||||
let prices: [String: Double]?
|
||||
|
||||
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
||||
/// any grade outside the 50–500 pence/litre band is dropped, so a relay
|
||||
/// regression (e.g. the band being removed server-side) can't re-poison
|
||||
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
|
||||
/// regression (e.g. the band being removed upstream) can't re-poison
|
||||
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
|
||||
var allPrices: [FuelType: Double] {
|
||||
var result = FuelPriceProvider.mapGrades(prices ?? [:])
|
||||
// Backwards-compat: relay versions without `prices` still send `price`.
|
||||
// Backwards-compat: mirror versions without `prices` still send `price`.
|
||||
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
|
||||
result[.e10] = price
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
let stations: [RelayStation]
|
||||
let stations: [MirrorStation]
|
||||
}
|
||||
|
||||
/// Envelope metadata from the relay response — which data source is live
|
||||
/// Envelope metadata from the mirror response — which data source is live
|
||||
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
|
||||
/// itself reports for the dataset. Displayed in Settings → About.
|
||||
struct RelayMeta: Codable, Equatable {
|
||||
@@ -189,7 +137,7 @@ struct RelayMeta: Codable, Equatable {
|
||||
|
||||
/// Representative stations spread across ENGLAND (one cluster per region) so
|
||||
/// the fallback works anywhere in the country — it is NOT tied to one town.
|
||||
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API).
|
||||
/// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API).
|
||||
/// Prices in pence/litre.
|
||||
struct SampleFuelProvider: FuelPriceProviding {
|
||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||
@@ -302,7 +250,7 @@ enum FuelProviderError: LocalizedError {
|
||||
case .notImplemented:
|
||||
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
||||
case .relayUnavailable:
|
||||
return "FuelBoard Relay unreachable — showing cached/sample data."
|
||||
return "FuelBoard live data unavailable — showing cached/sample data."
|
||||
case .mirrorUnavailable:
|
||||
return "GitHub price mirror unreachable."
|
||||
}
|
||||
|
||||
@@ -407,7 +407,6 @@ struct FuelStore {
|
||||
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
|
||||
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
||||
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
||||
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||
@@ -711,22 +710,6 @@ struct FuelStore {
|
||||
saveString(enabled ? "1" : "0", service: debugModeKey)
|
||||
}
|
||||
|
||||
// MARK: Relay fallback (dev-only)
|
||||
|
||||
/// Dev-only switch: re-inserts the LAN relay into the live chain as a
|
||||
/// fallback for home testing. OFF by default — consumers must never make
|
||||
/// a local-network attempt (the relay is unreachable off the developer's
|
||||
/// LAN, and the attempt itself would fire the iOS Local Network prompt).
|
||||
/// Toggled from Settings → Debug; keychain-first so it survives the
|
||||
/// delete → reinstall test loop like every other small setting.
|
||||
static func loadRelayFallbackEnabled() -> Bool {
|
||||
loadString(service: relayFallbackKey) == "1"
|
||||
}
|
||||
|
||||
static func saveRelayFallbackEnabled(_ enabled: Bool) {
|
||||
saveString(enabled ? "1" : "0", service: relayFallbackKey)
|
||||
}
|
||||
|
||||
// MARK: Favourites
|
||||
|
||||
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
||||
//
|
||||
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
|
||||
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
|
||||
// envelope, so the shared decode works verbatim. Fetching from
|
||||
// raw.githubusercontent.com makes live prices work fully off-LAN — App
|
||||
// Review cannot reach a LAN relay, and consumers never have one to reach —
|
||||
// and the bundled dump (app target) is the no-network last resort.
|
||||
// The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay)
|
||||
// is the PRODUCTION data source: its day files use the same /api/v1/stations
|
||||
// envelope, so the shared decoder works verbatim. Fetching from
|
||||
// raw.githubusercontent.com makes live prices work fully off-LAN — App Review
|
||||
// cannot reach a private LAN relay, and consumers never have one to reach —
|
||||
// with the bundled dump (app target) as the no-network last resort.
|
||||
//
|
||||
// Chain (LiveChainProvider) — GitHub first, EVERYWHERE:
|
||||
// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the
|
||||
// ContentView catch, widget: cache → placeholder)
|
||||
// The relay is NOT part of the consumer chain at all: it is unreachable from
|
||||
// any phone outside the developer's LAN, and even attempting it from a
|
||||
// consumer phone would fire the iOS Local Network permission prompt for an
|
||||
// address that can never be reached. It remains reachable only behind the
|
||||
// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home
|
||||
// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror
|
||||
// push) unchanged.
|
||||
//
|
||||
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
|
||||
// (page views/clones only), so every chain fetch opportunistically pings the
|
||||
// relay's existing widget-diag route (zero relay changes) — but ONLY when the
|
||||
// same dev flag is on, so consumers never make a local-network attempt.
|
||||
// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active.
|
||||
// Chain (LiveChainProvider) — public HTTPS only:
|
||||
// GitHub mirror → (app target: bundled dump in the ContentView catch,
|
||||
// widget: cache → placeholder)
|
||||
// No local-network fallback or private IP is compiled into the app binary.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -126,80 +114,24 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
||||
|
||||
// MARK: - Live chain provider
|
||||
|
||||
/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part
|
||||
/// of the consumer chain: consumers can never reach it (and must never even
|
||||
/// attempt it — that would prompt for Local Network access on a stranger's
|
||||
/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback`
|
||||
/// (Settings → Debug) for the developer's own home testing. Whichever leg
|
||||
/// serves is recorded for the About section + telemetry.
|
||||
/// The live price chain — public GitHub mirror only. No private LAN fallback is
|
||||
/// compiled into the app binary, so App Store/TestFlight builds cannot trigger
|
||||
/// Local Network permission or leak a private IP.
|
||||
struct LiveChainProvider: FuelPriceProviding {
|
||||
var mirror: FuelPriceProviding
|
||||
var relay: FuelPriceProviding
|
||||
|
||||
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
||||
/// Metadata surfaced in Settings → About.
|
||||
static var latestMeta: RelayMeta?
|
||||
static var lastSource: String?
|
||||
|
||||
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
||||
relay: FuelPriceProviding = RelayFuelProvider()) {
|
||||
init(mirror: FuelPriceProviding = MirrorFuelProvider()) {
|
||||
self.mirror = mirror
|
||||
self.relay = relay
|
||||
}
|
||||
|
||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||
do {
|
||||
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||
Self.lastSource = "github"
|
||||
FuelBeacon.fire(source: "github", n: stations.count)
|
||||
return stations
|
||||
} catch {
|
||||
// Dev-only fallback: consumers never reach this branch — the flag
|
||||
// is off by default and the flag itself lives in the Debug section.
|
||||
guard FuelStore.loadRelayFallbackEnabled() else { throw error }
|
||||
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||
Self.latestMeta = RelayFuelProvider.latestMeta
|
||||
Self.lastSource = "relay"
|
||||
FuelBeacon.fire(source: "relay", n: stations.count)
|
||||
return stations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Telemetry beacon
|
||||
|
||||
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
|
||||
/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
|
||||
/// dev relay flag is on — consumers must never make a local-network attempt,
|
||||
/// and an off-LAN ping failing silently is itself the reachability datum.
|
||||
/// Disabled in unit tests via `isEnabled`.
|
||||
enum FuelBeacon {
|
||||
static var isEnabled = true
|
||||
|
||||
static var shouldFire: Bool {
|
||||
isEnabled && FuelStore.loadRelayFallbackEnabled()
|
||||
}
|
||||
|
||||
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
|
||||
guard shouldFire,
|
||||
let url = beaconURL(source: source, n: n) else { return }
|
||||
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
Task { _ = try? await URLSession.shared.data(for: request) }
|
||||
}
|
||||
|
||||
/// Testable URL construction — hits the relay's existing widget-diag
|
||||
/// beacon route with app attribution (zero relay changes).
|
||||
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: base.appendingPathComponent("api/v1/widget-diag"),
|
||||
resolvingAgainstBaseURL: false
|
||||
) else { return nil }
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "intent", value: "app-live"),
|
||||
URLQueryItem(name: "source", value: source),
|
||||
URLQueryItem(name: "n", value: String(n)),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user