Consumer chain: GitHub-only — relay removed from app+widget, Local Network permission gone
The LAN relay is unreachable from any phone outside the developer's LAN, so for App Store users a relay fallback was dead weight AND harmful: attempting the private IP fires the iOS Local Network prompt on a stranger's phone and adds a timeout before the bundled dump. GitHub raw is reachable anywhere the relay would be, and more reliably. - LiveChainProvider: GitHub mirror first EVERYWHERE (focused alert path no longer relay-first); LAN relay only joins behind the hidden dev flag fuelboard.relayFallback (Settings → Debug, keychain-first, off by default) - MirrorFuelProvider: focused() projection replaces the relay's server-side radius endpoint (fuel filter + within-radius + nearest-first + limit 500) — the alert path re-checks radius/fuel itself, so behaviour is identical - Widget: fetchFocused/fetchFuelOnly now GitHub-first via the chain (day-cache shared through the app group); relay + widget-diag GET gated on the same dev flag — off-LAN widgets no longer attempt local network at all - FuelBeacon: fires only when the dev flag is on — consumers make zero local-network attempts; /stats app-hit spike = GitHub down + dev flag on - Onboarding: Local Network machinery removed entirely (no probe, no prompt, no 12 s stall) — data page is now informational: prices download from the internet, no permissions needed. Replay on cellular can no longer hang. - Info.plist: NSLocalNetworkUsageDescription copy → optional dev-only wording - 95 tests (6 new: relay-skip-when-disabled x2, focused prefer-mirror, mirror focused projection x3, beacon dev-flag gate)
This commit is contained in:
@@ -56,31 +56,9 @@ struct OnboardingView: View {
|
||||
.onChange(of: prompter.notificationsGranted) { _, granted in
|
||||
if granted, page == 2 { page = 3 }
|
||||
}
|
||||
.onChange(of: prompter.dataGranted) { _, granted in
|
||||
if granted, page == 3 {
|
||||
// The probe result lands while the Local Network prompt is
|
||||
// still dismissing, so without a beat the green "Connected"
|
||||
// tick is skipped and the page jumps straight to Done —
|
||||
// unlike the Location/Notifications pages where the granted
|
||||
// tick is visible. Hold the tick on screen, then advance.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
if page == 3 { page = 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replay path (test button / returning user): the Local Network
|
||||
// permission is already decided, so probe silently up front. The
|
||||
// probe fires no prompt once the permission is granted or denied —
|
||||
// only the FIRST attempt (undetermined) shows the system alert, and
|
||||
// that only happens on first run. By the time the data page appears
|
||||
// its status is known, so it shows the green tick (or denied text)
|
||||
// immediately, matching the Location and Notifications pages.
|
||||
.onAppear {
|
||||
if FuelStore.loadHasCompletedOnboarding() {
|
||||
prompter.preflightDataAccess()
|
||||
}
|
||||
}
|
||||
// No data-permission step: prices download from the internet (GitHub
|
||||
// mirror) with no permission at all — Local Network was removed from
|
||||
// the app when the relay left the consumer chain.
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
||||
@@ -207,47 +185,17 @@ struct OnboardingView: View {
|
||||
Text("Prices, ready when you are")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.")
|
||||
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 12)
|
||||
|
||||
dataStatusLabel
|
||||
.padding(.top, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dataStatusLabel: some View {
|
||||
if prompter.dataLoading {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Waiting for network permission…")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if prompter.dataGranted {
|
||||
// Same confirmation styling as the Location/Notifications pages.
|
||||
Label("Connected", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.subheadline)
|
||||
} else if prompter.dataDenied {
|
||||
Text("Local network access was denied — prices won't load until it's allowed, but you can keep using FuelBoard.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
} else {
|
||||
Text("The system prompt will appear next.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var donePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -309,18 +257,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
|
||||
if prompter.dataGranted || prompter.dataDenied {
|
||||
// Prompt answered (allowed or denied) — move on to the
|
||||
// final slide. Never dead-end on an Open Settings button.
|
||||
page = 4
|
||||
} else if !prompter.dataLoading {
|
||||
// Fires the Local Network prompt + relay probe here,
|
||||
// after the user has read the page and tapped Continue.
|
||||
prompter.requestDataAccess()
|
||||
}
|
||||
}
|
||||
.disabled(prompter.dataLoading)
|
||||
// No permission on this page — prices need none. Straight on.
|
||||
primaryButton("Continue") { page = 4 }
|
||||
default:
|
||||
primaryButton("Start Using FuelBoard") { finish() }
|
||||
}
|
||||
@@ -390,13 +328,6 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
||||
@Published private(set) var locationDenied = false
|
||||
@Published private(set) var notificationsGranted = false
|
||||
@Published private(set) var notificationsDenied = false
|
||||
@Published private(set) var dataGranted = false
|
||||
@Published private(set) var dataDenied = false
|
||||
@Published private(set) var dataLoading = false
|
||||
|
||||
/// Held while the Local Network permission prompt is pending — keeps the
|
||||
/// bare TCP connect alive until the user answers (nil after resolve).
|
||||
private var dataConnection: NWConnection?
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
@@ -438,125 +369,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers the Local Network permission prompt WITHOUT loading any data.
|
||||
/// iOS shows the prompt on the first attempt to reach a local-network
|
||||
/// address, so a bare TCP connect to the relay host:port is enough — no
|
||||
/// HTTP request, no payload, nothing to wait on. Once the user answers
|
||||
/// the prompt the connect either succeeds (granted) or fails (denied);
|
||||
/// either way onboarding proceeds. The full dataset download happens
|
||||
/// AFTER onboarding completes (ContentView's forced refresh).
|
||||
func requestDataAccess() {
|
||||
guard !dataLoading else { return }
|
||||
dataLoading = true
|
||||
dataDenied = false
|
||||
let baseURL = RelayFuelProvider().baseURL
|
||||
guard let host = baseURL.host,
|
||||
let port = baseURL.port else {
|
||||
dataLoading = false
|
||||
dataDenied = true
|
||||
return
|
||||
}
|
||||
let connection = NWConnection(
|
||||
host: NWEndpoint.Host(host),
|
||||
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
||||
using: .tcp
|
||||
)
|
||||
dataConnection = connection
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
// Connect succeeded — local network allowed.
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
self.dataGranted = true
|
||||
self.dataDenied = false
|
||||
case .failed, .cancelled:
|
||||
// Connect failed (denied, or relay unreachable). Either
|
||||
// way we do NOT dead-end: onboarding continues.
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
self.dataGranted = false
|
||||
self.dataDenied = true
|
||||
default:
|
||||
break // .preparing / .waiting — prompt pending, hold on
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.start(queue: .main)
|
||||
// Safety timeout: if the prompt is ignored or the connect stalls, drop
|
||||
// back to the enabled Continue button instead of an endless spinner.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 12_000_000_000)
|
||||
guard let self, self.dataLoading else { return }
|
||||
self.dataLoading = false
|
||||
self.dataGranted = false
|
||||
self.dataDenied = true
|
||||
self.dataConnection = nil
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay pre-flight: determines the Local Network status WITHOUT any
|
||||
/// user-visible check. Once the permission is decided (granted or denied)
|
||||
/// a probe resolves silently — granted connects in milliseconds, denied
|
||||
/// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`.
|
||||
/// Only the FIRST-ever attempt (undetermined) shows the system alert; in
|
||||
/// that case we cancel and leave the state unknown so the data page's
|
||||
/// Continue triggers the prompt in context. First run never calls this.
|
||||
func preflightDataAccess() {
|
||||
guard !dataLoading, !dataGranted, !dataDenied else { return }
|
||||
dataLoading = true
|
||||
let baseURL = RelayFuelProvider().baseURL
|
||||
guard let host = baseURL.host,
|
||||
let port = baseURL.port else {
|
||||
dataLoading = false
|
||||
return
|
||||
}
|
||||
let connection = NWConnection(
|
||||
host: NWEndpoint.Host(host),
|
||||
port: NWEndpoint.Port(rawValue: UInt16(port))!,
|
||||
using: .tcp
|
||||
)
|
||||
dataConnection = connection
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
self.dataGranted = true
|
||||
self.dataDenied = false
|
||||
case .waiting:
|
||||
if connection.currentPath?.unsatisfiedReason == .localNetworkDenied {
|
||||
// Denied in Settings — resolved silently, no prompt.
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
self.dataGranted = false
|
||||
self.dataDenied = true
|
||||
} else {
|
||||
// Prompt pending (permission undetermined — only
|
||||
// possible after a reinstall, where the onboarding
|
||||
// flag persisted but the permission reset). Cancel so
|
||||
// page 4's Continue fires the prompt in context.
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
connection.cancel()
|
||||
}
|
||||
case .failed, .cancelled:
|
||||
self.dataConnection = nil
|
||||
self.dataLoading = false
|
||||
self.dataGranted = false
|
||||
self.dataDenied = true
|
||||
default:
|
||||
break // .preparing — probe starting, hold on
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.start(queue: .main)
|
||||
}
|
||||
/// Replay pre-flight: NO data permission exists anymore — prices download
|
||||
/// from the internet with no prompt, so there is nothing to preflight.
|
||||
/// (The LAN relay left the consumer chain; Local Network is gone.)
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
Task { @MainActor in
|
||||
|
||||
Reference in New Issue
Block a user