Onboarding: network page no longer loads data — bare TCP connect

- The Data page's probe was an HTTP GET /api/v1/stations (8s timeout)
  that read as 'loading data' and could stall the slide. Replaced with a
  bare TCP connect to the relay host:port — just enough to trigger the
  iOS Local Network permission prompt, zero payload transferred.
- Grant -> auto-advance to the final slide (existing onChange); denial or
  unreachable relay -> Continue re-enables and proceeds. 12s safety
  timeout so the page can never dead-end on a spinner.
- NWConnection held on the prompter so the connect survives until the
  prompt is answered. Status copy: 'Waiting for network permission…'.
  Full dataset still downloads AFTER onboarding via forced refresh.
  35 tests pass.
This commit is contained in:
FuelBoard Contributor
2026-08-12 15:19:00 +01:00
parent 55ab4537b5
commit 3988dea30c
+56 -29
View File
@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import CoreLocation import CoreLocation
import UserNotifications import UserNotifications
import Network
/// First-launch onboarding: introduces FuelBoard, then walks the user through /// First-launch onboarding: introduces FuelBoard, then walks the user through
/// the three system permissions (location, notifications, local network for /// the three system permissions (location, notifications, local network for
@@ -201,7 +202,7 @@ struct OnboardingView: View {
if prompter.dataLoading { if prompter.dataLoading {
VStack(spacing: 10) { VStack(spacing: 10) {
ProgressView() ProgressView()
Text("Checking the FuelBoard Relay") Text("Waiting for network permission")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -282,7 +283,7 @@ struct OnboardingView: View {
} }
} }
case 3: case 3:
primaryButton(prompter.dataLoading ? "Checking…" : "Continue") { primaryButton(prompter.dataLoading ? "Waiting…" : "Continue") {
if prompter.dataGranted || prompter.dataDenied { if prompter.dataGranted || prompter.dataDenied {
// Prompt answered (allowed or denied) move on to the // Prompt answered (allowed or denied) move on to the
// final slide. Never dead-end on an Open Settings button. // final slide. Never dead-end on an Open Settings button.
@@ -367,6 +368,10 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
@Published private(set) var dataDenied = false @Published private(set) var dataDenied = false
@Published private(set) var dataLoading = 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() private let manager = CLLocationManager()
override init() { override init() {
@@ -407,42 +412,64 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
} }
} }
/// Probes the FuelBoard Relay. The first connection to a local-network /// Triggers the Local Network permission prompt WITHOUT loading any data.
/// address is what makes iOS show the Local Network permission prompt /// iOS shows the prompt on the first attempt to reach a local-network
/// so this both triggers the prompt in context and verifies data will /// address, so a bare TCP connect to the relay host:port is enough no
/// actually load. A small request (limit 1) is enough; the full download /// HTTP request, no payload, nothing to wait on. Once the user answers
/// happens after onboarding completes. /// 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() { func requestDataAccess() {
guard !dataLoading else { return } guard !dataLoading else { return }
dataLoading = true dataLoading = true
dataDenied = false dataDenied = false
Task { let baseURL = RelayFuelProvider().baseURL
var ok = false guard let host = baseURL.host,
defer { let port = baseURL.port else {
dataLoading = false dataLoading = false
dataGranted = ok dataDenied = true
dataDenied = !ok return
} }
do { let connection = NWConnection(
var components = URLComponents( host: NWEndpoint.Host(host),
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), port: NWEndpoint.Port(rawValue: UInt16(port))!,
resolvingAgainstBaseURL: false using: .tcp
)! )
components.queryItems = [ dataConnection = connection
URLQueryItem(name: "fuel", value: FuelType.e10.rawValue), connection.stateUpdateHandler = { [weak self] state in
URLQueryItem(name: "limit", value: "1"), Task { @MainActor in
] guard let self else { return }
var request = URLRequest(url: components.url!) switch state {
request.timeoutInterval = 8 case .ready:
let (_, response) = try await URLSession.shared.data(for: request) // Connect succeeded local network allowed.
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { self.dataConnection = nil
throw FuelProviderError.relayUnavailable 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
} }
ok = true
} catch {
ok = false
} }
} }
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()
}
} }
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {