From 3988dea30cab427ba4b89bee229dcb6ed24be33d Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 15:19:00 +0100 Subject: [PATCH] =?UTF-8?q?Onboarding:=20network=20page=20no=20longer=20lo?= =?UTF-8?q?ads=20data=20=E2=80=94=20bare=20TCP=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- FuelBoard/OnboardingView.swift | 89 ++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index 98d678e..bf47d05 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -1,6 +1,7 @@ import SwiftUI import CoreLocation import UserNotifications +import Network /// First-launch onboarding: introduces FuelBoard, then walks the user through /// the three system permissions (location, notifications, local network for @@ -201,7 +202,7 @@ struct OnboardingView: View { if prompter.dataLoading { VStack(spacing: 10) { ProgressView() - Text("Checking the FuelBoard Relay…") + Text("Waiting for network permission…") .font(.footnote) .foregroundStyle(.secondary) } @@ -282,7 +283,7 @@ struct OnboardingView: View { } } case 3: - primaryButton(prompter.dataLoading ? "Checking…" : "Continue") { + 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. @@ -367,6 +368,10 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur @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() override init() { @@ -407,42 +412,64 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur } } - /// Probes the FuelBoard Relay. The first connection to a local-network - /// address is what makes iOS show the Local Network permission prompt — - /// so this both triggers the prompt in context and verifies data will - /// actually load. A small request (limit 1) is enough; the full download - /// happens after onboarding completes. + /// 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 - Task { - var ok = false - defer { - dataLoading = false - dataGranted = ok - dataDenied = !ok - } - do { - var components = URLComponents( - url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"), - resolvingAgainstBaseURL: false - )! - components.queryItems = [ - URLQueryItem(name: "fuel", value: FuelType.e10.rawValue), - URLQueryItem(name: "limit", value: "1"), - ] - var request = URLRequest(url: components.url!) - request.timeoutInterval = 8 - let (_, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw FuelProviderError.relayUnavailable + 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 } - 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) {