P0: live provider chain — GitHub mirror primary, relay fallback, bundled dump last resort + app telemetry beacon
- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent (latest.json pointer → history/<day>.json full dump), app-group day-cache so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day - LiveChainProvider: full path (app refresh) GitHub → relay; focused path (background alert checks) relay-first so alerts never pull the full dump over mobile data; records which leg served for About + telemetry - FuelBeacon: fire-and-forget X-Client app ping to the relay's existing widget-diag route (zero relay changes) — attribution + cadence on-LAN, silent skip off-LAN is the reachability datum; /stats app-hit spike = GitHub path failing - Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022 stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh — no-network last resort replaces the demo sample set - ContentView: About meta from the chain; catch falls back cached → bundled → sample - 89 tests (6 new: chain order x4, cache decision, beacon URL); Release build green; beacon route verified against the live relay (204 + logged)
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"data" : [
|
||||||
|
{
|
||||||
|
"filename" : "fuelboard_dump.json",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
|||||||
|
// BundledDumpProvider.swift — no-network last resort (P0 live chain).
|
||||||
|
//
|
||||||
|
// A REAL snapshot of the mirror dump is bundled into the app (FuelBoardDump
|
||||||
|
// dataset, refreshed before builds by scripts/refresh_bundled_dump.sh) so the
|
||||||
|
// app never demos empty and App Review always sees real data even fully
|
||||||
|
// offline. Same relay envelope, so the shared decode applies unchanged.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum BundledDumpProvider {
|
||||||
|
/// The bundled real snapshot, decoded with the shared relay decode —
|
||||||
|
/// nil only if the asset is missing or corrupt (callers then fall back
|
||||||
|
/// to the England-wide sample set).
|
||||||
|
static var stations: [FuelStation]? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
|
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -454,9 +454,10 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
// Persist relay envelope metadata (source, station count, GOV.UK
|
// Persist envelope metadata (source, station count, GOV.UK
|
||||||
// dataset update time) for the Settings → About section.
|
// dataset update time) for the Settings → About section — the
|
||||||
if let meta = RelayFuelProvider.latestMeta {
|
// live chain records whichever leg served the fetch.
|
||||||
|
if let meta = LiveChainProvider.latestMeta {
|
||||||
FuelStore.saveRelayMeta(meta)
|
FuelStore.saveRelayMeta(meta)
|
||||||
}
|
}
|
||||||
// Keep the keychain favourites fresh with the new prices — the
|
// Keep the keychain favourites fresh with the new prices — the
|
||||||
@@ -467,8 +468,14 @@ struct ContentView: View {
|
|||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
|
if FuelStore.loadStations().isEmpty {
|
||||||
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
|
// (stale but genuine), then the demo sample set.
|
||||||
|
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||||||
|
} else {
|
||||||
|
stations = FuelStore.loadStations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/MirrorFuelProvider.swift
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// P0 live chain: GitHub mirror → LAN relay → bundled dump.
|
||||||
|
final class LiveChainTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
FuelBeacon.isEnabled = false // chain tests must not fire real pings
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
FuelBeacon.isEnabled = true
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fixtures
|
||||||
|
|
||||||
|
private final class StubProvider: FuelPriceProviding {
|
||||||
|
var result: [FuelStation]
|
||||||
|
var error: Error?
|
||||||
|
private(set) var callCount = 0
|
||||||
|
|
||||||
|
init(result: [FuelStation]? = nil, error: Error? = nil) {
|
||||||
|
self.result = result ?? []
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
callCount += 1
|
||||||
|
if let error { throw error }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fixtureStation(_ id: String = "s1") -> FuelStation {
|
||||||
|
FuelStation(id: id, name: "Station \(id)", brand: "Test", address: "1 High St",
|
||||||
|
postcode: "SW1A 1AA", lat: 53.7, lng: -1.8,
|
||||||
|
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Full path (app refresh): GitHub first
|
||||||
|
|
||||||
|
func testFullPathPrefersMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation()])
|
||||||
|
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.count, 1)
|
||||||
|
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
|
||||||
|
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullPathFallsBackToRelay() async throws {
|
||||||
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "relay")
|
||||||
|
XCTAssertEqual(relay.callCount, 1, "relay must serve when the mirror is down")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Focused path (alert checks): relay first — never pull the full
|
||||||
|
// ~2.8 MB dump over mobile data for a background alert when the relay is up.
|
||||||
|
|
||||||
|
func testFocusedPathPrefersRelay() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||||
|
let relay = StubProvider(result: [fixtureStation("relay")])
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
|
||||||
|
XCTAssertEqual(relay.callCount, 1, "focused alert fetch must prefer the light relay call")
|
||||||
|
XCTAssertEqual(mirror.callCount, 0, "mirror full dump must NOT be pulled when the relay is up")
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFocusedPathFallsBackToMirror() async throws {
|
||||||
|
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||||
|
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
||||||
|
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
||||||
|
|
||||||
|
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
|
||||||
|
XCTAssertEqual(stations.first?.id, "mirror")
|
||||||
|
XCTAssertEqual(mirror.callCount, 1)
|
||||||
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Mirror day-cache decision
|
||||||
|
|
||||||
|
func testDumpCacheReuseDecision() {
|
||||||
|
XCTAssertTrue(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: "2026-08-15"),
|
||||||
|
"same day → reuse the cached dump, no ~2.8 MB re-download")
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-14", latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: nil, latestDay: "2026-08-15"))
|
||||||
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Beacon URL
|
||||||
|
|
||||||
|
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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ protocol FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelPriceProvider {
|
enum FuelPriceProvider {
|
||||||
/// Default: the keyless relay (full-UK Fuel Finder data). Falls back to
|
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
|
||||||
/// the England-wide sample set when the relay is unreachable.
|
/// relay (fallback) → bundled dump (last resort in the app target).
|
||||||
static let active: FuelPriceProviding = RelayFuelProvider()
|
static let active: FuelPriceProviding = LiveChainProvider()
|
||||||
|
|
||||||
/// Decodes a relay payload into stations, applying the defensive price
|
/// Decodes a relay payload into stations, applying the defensive price
|
||||||
/// band. Internal so the unit-test target can exercise the guard.
|
/// band. Internal so the unit-test target can exercise the guard.
|
||||||
@@ -296,12 +296,15 @@ struct FuelFinderProvider: FuelPriceProviding {
|
|||||||
enum FuelProviderError: LocalizedError {
|
enum FuelProviderError: LocalizedError {
|
||||||
case notImplemented
|
case notImplemented
|
||||||
case relayUnavailable
|
case relayUnavailable
|
||||||
|
case mirrorUnavailable
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notImplemented:
|
case .notImplemented:
|
||||||
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
||||||
case .relayUnavailable:
|
case .relayUnavailable:
|
||||||
return "FuelBoard Relay unreachable — showing cached/sample data."
|
return "FuelBoard Relay unreachable — showing cached/sample data."
|
||||||
|
case .mirrorUnavailable:
|
||||||
|
return "GitHub price mirror unreachable."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// 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 the LAN relay — and the bundled dump stays as the
|
||||||
|
// no-network last resort.
|
||||||
|
//
|
||||||
|
// Chain (LiveChainProvider):
|
||||||
|
// full path (app refresh): GitHub mirror → LAN relay → bundled dump
|
||||||
|
// focused path (alert fetch): LAN relay (small radius fetch) → GitHub
|
||||||
|
// mirror (full decode, device filters) → dump
|
||||||
|
// The focused path stays relay-first so a background alert check never pulls
|
||||||
|
// the ~2.8 MB full dump over mobile data when the relay is reachable.
|
||||||
|
//
|
||||||
|
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
|
||||||
|
// (page views/clones only), so every chain fetch fires an opportunistic
|
||||||
|
// fire-and-forget X-Client "app" ping at the relay's existing widget-diag
|
||||||
|
// route (zero relay changes). On-LAN = attribution + cadence; off-LAN the
|
||||||
|
// ping fails in ~2 s and the skip itself is the reachability datum. The
|
||||||
|
// relay's /stats app-hit spike doubles as the fallback-outage signal.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Mirror provider (GitHub raw)
|
||||||
|
|
||||||
|
/// Fetches CURRENT prices from the GitHub mirror. Same envelope as the
|
||||||
|
/// relay, so `FuelPriceProvider.decodeStations` applies unchanged.
|
||||||
|
struct MirrorFuelProvider: FuelPriceProviding {
|
||||||
|
var baseURL = FuelHistoryStore.mirrorBase
|
||||||
|
|
||||||
|
/// Envelope metadata from the most recent successful mirror fetch —
|
||||||
|
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
||||||
|
static var latestMeta: RelayMeta?
|
||||||
|
|
||||||
|
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
||||||
|
/// The mirror pushes once/day, so between pushes the app reuses the
|
||||||
|
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
||||||
|
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
||||||
|
|
||||||
|
struct DumpCache: Codable {
|
||||||
|
let day: String
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDumpCache() -> DumpCache? {
|
||||||
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
|
let raw = defaults.data(forKey: liveDumpCacheKey),
|
||||||
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDumpCache(day: String, data: Data) {
|
||||||
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
|
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
||||||
|
defaults.set(raw, forKey: liveDumpCacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the cached dump is already the freshest the mirror has —
|
||||||
|
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
|
||||||
|
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
|
||||||
|
guard let cachedDay, let latestDay else { return false }
|
||||||
|
return cachedDay == latestDay
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||||
|
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||||
|
let day = latest.availableTo ?? latest.date else {
|
||||||
|
throw FuelProviderError.mirrorUnavailable
|
||||||
|
}
|
||||||
|
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
|
||||||
|
let cache = Self.loadDumpCache()
|
||||||
|
let data: Data
|
||||||
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||||
|
data = cached
|
||||||
|
} else {
|
||||||
|
let (fetched, response) = try await URLSession.shared.data(
|
||||||
|
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
||||||
|
)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw FuelProviderError.mirrorUnavailable
|
||||||
|
}
|
||||||
|
Self.saveDumpCache(day: day, data: fetched)
|
||||||
|
data = fetched
|
||||||
|
}
|
||||||
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||||
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live chain provider
|
||||||
|
|
||||||
|
/// The live price chain. Full path prefers the GitHub mirror; the focused
|
||||||
|
/// path (alert checks) stays relay-first so a background alert never pulls
|
||||||
|
/// the full dump over mobile data when the relay is up. Whichever leg serves
|
||||||
|
/// is recorded for telemetry + the About section.
|
||||||
|
struct LiveChainProvider: FuelPriceProviding {
|
||||||
|
var mirror: FuelPriceProviding
|
||||||
|
var relay: FuelPriceProviding
|
||||||
|
|
||||||
|
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
||||||
|
static var latestMeta: RelayMeta?
|
||||||
|
static var lastSource: String?
|
||||||
|
|
||||||
|
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
||||||
|
relay: FuelPriceProviding = RelayFuelProvider()) {
|
||||||
|
self.mirror = mirror
|
||||||
|
self.relay = relay
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
|
if radiusKM != nil {
|
||||||
|
// Focused alert fetch: light relay radius call first, mirror fallback.
|
||||||
|
do {
|
||||||
|
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
Self.latestMeta = RelayFuelProvider.latestMeta
|
||||||
|
Self.lastSource = "relay"
|
||||||
|
FuelBeacon.fire(source: "relay", n: stations.count)
|
||||||
|
return stations
|
||||||
|
} catch {
|
||||||
|
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||||
|
Self.lastSource = "github"
|
||||||
|
FuelBeacon.fire(source: "github", n: stations.count)
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Full dump: GitHub first (off-LAN + App Review), relay fallback.
|
||||||
|
do {
|
||||||
|
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 {
|
||||||
|
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 (off-LAN) is silent and IS the
|
||||||
|
/// reachability datum. Disabled in unit tests via `isEnabled`.
|
||||||
|
enum FuelBeacon {
|
||||||
|
static var isEnabled = true
|
||||||
|
|
||||||
|
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
|
||||||
|
guard isEnabled,
|
||||||
|
let url = beaconURL(source: source, n: n) else { return }
|
||||||
|
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
Task { _ = try? await URLSession.shared.data(for: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Testable URL construction — hits the relay's existing widget-diag
|
||||||
|
/// beacon route with app attribution (zero relay changes).
|
||||||
|
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: base.appendingPathComponent("api/v1/widget-diag"),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else { return nil }
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "intent", value: "app-live"),
|
||||||
|
URLQueryItem(name: "source", value: source),
|
||||||
|
URLQueryItem(name: "n", value: String(n)),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Refresh the bundled fallback dump (FuelBoardDump dataset) from the GitHub
|
||||||
|
# mirror — run before a Release build so the IPA carries the freshest REAL
|
||||||
|
# snapshot (the app's no-network last resort). Idempotent: skips when the
|
||||||
|
# bundled dump already carries the mirror's data_updated stamp.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
TARGET="FuelBoard/Assets.xcassets/FuelBoardDump.dataset/fuelboard_dump.json"
|
||||||
|
RAW="https://raw.githubusercontent.com/aptonline/fuelboard-data/main"
|
||||||
|
LATEST="$(curl -fsSL "$RAW/latest.json")"
|
||||||
|
DAY="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("available_to") or d.get("date") or "")')"
|
||||||
|
UPD="$(printf '%s' "$LATEST" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("data_updated") or "")')"
|
||||||
|
if [ -z "$DAY" ]; then echo "no snapshot day in latest.json" >&2; exit 1; fi
|
||||||
|
if [ -f "$TARGET" ] && grep -q "\"data_updated\": \"$UPD\"" "$TARGET"; then
|
||||||
|
echo "bundled dump already current ($DAY, $UPD)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "fetching history/$DAY.json ($UPD)…"
|
||||||
|
curl -fsSL "$RAW/history/$DAY.json" -o "$TARGET"
|
||||||
|
echo "updated $TARGET — $DAY"
|
||||||
Reference in New Issue
Block a user