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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user