138 lines
6.2 KiB
Swift
138 lines
6.2 KiB
Swift
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
|
//
|
|
// 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) — 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
|
|
|
|
// 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 file 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"
|
|
static let liveDumpCacheFileName = "fuelboard.liveDumpCache.json"
|
|
|
|
struct DumpCache: Codable {
|
|
let day: String
|
|
let data: Data
|
|
}
|
|
|
|
static func loadDumpCache() -> DumpCache? {
|
|
if let raw = FuelStore.loadAppGroupFile(liveDumpCacheFileName),
|
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) {
|
|
return cache
|
|
}
|
|
|
|
// Migrate the legacy UserDefaults cache when present.
|
|
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
|
|
}
|
|
if FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) {
|
|
defaults.removeObject(forKey: liveDumpCacheKey)
|
|
}
|
|
return cache
|
|
}
|
|
|
|
static func saveDumpCache(day: String, data: Data) {
|
|
guard let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
|
_ = FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName)
|
|
}
|
|
|
|
/// True when the cached dump is already the freshest the mirror has —
|
|
/// avoids the ~2.8 MB re-download when the mirror hasn't pushed a new day.
|
|
static func canReuseCache(cachedDay: String?, latestDay: String?) -> Bool {
|
|
guard let cachedDay, let latestDay else { return false }
|
|
return cachedDay == latestDay
|
|
}
|
|
|
|
/// Focused-path projection, replacing what the relay's radius endpoint
|
|
/// used to do server-side: stations WITH the fuel, within radius of the
|
|
/// point, nearest first, bounded. The alert path re-checks radius and
|
|
/// fuel itself afterwards, so this only narrows the payload.
|
|
static func focused(_ stations: [FuelStation], near lat: Double?, lng: Double?,
|
|
fuel: FuelType, radiusKM: Double?, limit: Int = 500) -> [FuelStation] {
|
|
guard let lat, let lng, let radiusKM else { return stations }
|
|
return Array(
|
|
stations
|
|
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
|
.prefix(limit)
|
|
)
|
|
}
|
|
|
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
|
// 1. Tiny pointer fetch — the freshest snapshot day.
|
|
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
|
let day = latest.availableTo ?? latest.date else {
|
|
throw FuelProviderError.mirrorUnavailable
|
|
}
|
|
// 2. Reuse the cached full dump when the mirror hasn't pushed a new day.
|
|
let cache = Self.loadDumpCache()
|
|
let data: Data
|
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
|
data = cached
|
|
} else {
|
|
// Bypass the HTTP cache + short timeout: an offline refresh must
|
|
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
|
// a "successful" live fetch.
|
|
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
|
req.cachePolicy = .reloadIgnoringLocalCacheData
|
|
req.timeoutInterval = 10
|
|
let (fetched, response) = try await URLSession.shared.data(for: req)
|
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
|
throw FuelProviderError.mirrorUnavailable
|
|
}
|
|
Self.saveDumpCache(day: day, data: fetched)
|
|
data = fetched
|
|
}
|
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
|
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
|
}
|
|
}
|
|
|
|
// MARK: - Live chain provider
|
|
|
|
/// The live price chain — 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
|
|
|
|
/// Metadata surfaced in Settings → About.
|
|
static var latestMeta: RelayMeta?
|
|
static var lastSource: String?
|
|
|
|
init(mirror: FuelPriceProviding = MirrorFuelProvider()) {
|
|
self.mirror = mirror
|
|
}
|
|
|
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
|
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
|
Self.latestMeta = MirrorFuelProvider.latestMeta
|
|
Self.lastSource = "github"
|
|
return stations
|
|
}
|
|
}
|