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 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 {
let baseURL = RelayFuelProvider().baseURL
guard let host = baseURL.host,
let port = baseURL.port else {
dataLoading = false
dataGranted = ok
dataDenied = !ok
dataDenied = true
return
}
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 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) {