diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index afa59a1..f9dddd8 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -499,8 +499,12 @@ private struct OpenWindowView: View { ) } Origin89Notice( - "Press the pairing button on the controller's panel. The window stays open for 120 seconds, and pairing must finish inside it." + "Press the pairing button on the controller's panel. A controller no phone has paired with also opens it for 120 seconds after it powers on. The window stays open for 120 seconds, and pairing must finish inside it." ) + Text( + "This phone connects over Bluetooth once the window is open. The controller's Wi-Fi is off until pairing ends." + ) + .foregroundStyle(.secondary) Button("The window is open, pair now", action: opened) .buttonStyle(.borderedProminent) Spacer() @@ -561,13 +565,23 @@ private struct WrittenView: View { var body: some View { Form { Section { - Text("Network settings saved") - Origin89Status("Saved", tone: .nominal) - Text( - flow.reportsWiFi - ? "The controller accepted version \(version) and passes it to its radio. It may drop the Bluetooth connection while it joins the network. Once it joins, this phone continues over Wi-Fi if it is on the same network." - : "The controller accepted version \(version) and passes it to its radio. Watch the module join the network." - ) + if flow.joinsHeldNetwork { + Text("Network settings kept") + Origin89Status("Kept", tone: .nominal) + Text( + "The controller already holds \(flow.network?.ssid ?? "a network") and joins it now that pairing ended. It may drop the Bluetooth connection while it joins. Once it joins, this phone continues over Wi-Fi if it is on the same network." + ) + } else { + Text("Network settings saved") + Origin89Status("Saved", tone: .nominal) + Text( + flow.reportsWiFi + ? "The controller accepted version \(version) and passes it to its radio. It may drop the Bluetooth connection while it joins the network. Once it joins, this phone continues over Wi-Fi if it is on the same network." + : "The controller accepted version \(version) and passes it to its radio. Watch the module join the network." + ) + } + Button("Choose another network") { Task { await flow.changeNetwork() } } + .disabled(flow.isSwitchingToWiFi) } if flow.reportsWiFi { JoinSection(join: flow.join, flow: flow) } LinkSection(flow: flow) @@ -649,10 +663,17 @@ private struct JoinSection: View { Button("Check again") { Task { await flow.watchJoinAgain() } } case .connectionLost: Origin89Status("Unknown", tone: .warning) - Text( - "The Bluetooth connection ended before the controller said whether it joined. The network settings are saved." - ) - Button("Check again") { Task { await flow.watchJoinAgain() } } + if let unavailable = flow.wifiUnavailable { + Text( + "The Bluetooth connection ended before the controller said whether it joined, and this phone did not find it on Wi-Fi within 30 seconds. The network settings are saved." + ) + Origin89Notice(unavailable.reason) + } else { + Text( + "The Bluetooth connection ended before the controller said whether it joined. The network settings are saved." + ) + } + Button("Check again over Wi-Fi or Bluetooth") { Task { await flow.watchJoinAgain() } } } } header: { Text("Wi-Fi") diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index 27ea052..516a472 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -9,14 +9,19 @@ import SetupKit let usage = """ usage: setup-bench [options] - pair [--window-open] Pair with the controller whose code is in $ORIGIN89_SETUP_CODE + pair [--window-open] [--ssid NAME] [--watch SECONDS] + Pair with the controller whose code is in $ORIGIN89_SETUP_CODE and read its network. Without --window-open it waits for Enter - once the pairing window is open on the panel. + once the pairing window is open on the panel. A controller that + holds a network joins it: watch that (default 60 s). With + --ssid, a controller without one gets that network written on + the pairing connection, as write does. read Continue from the kept enrolment and read the network section. scan Read, then run a Wi-Fi scan to its end. write --ssid NAME [--country CC] [--hostname NAME] [--watch SECONDS] Read, then write the network with the passphrase in $ORIGIN89_WIFI_PASSPHRASE, and watch the join (default 60 s). + clear Read, then write no network, keeping the country and hostname. hold --seconds N Read, stay connected and idle for N seconds, then send a request. options: @@ -260,6 +265,7 @@ func label(_ state: SetupFlow.State) -> String { await flow.reset() case "scan": try await scan() case "write": try await write() + case "clear": try await clear() case "hold": try await hold() case "help": print(usage) default: throw BenchError(usage) @@ -282,12 +288,27 @@ func label(_ state: SetupFlow.State) -> String { } await flow.confirmWindowOpened() } - guard case .editingNetwork(let settings) = flow.state, let deviceID = flow.controller?.deviceID - else { throw BenchError("pairing stopped: \(label(flow.state))") } + guard let deviceID = flow.controller?.deviceID, let settings = flow.network else { + throw BenchError("pairing stopped: \(label(flow.state))") + } + switch flow.state { + case .editingNetwork, .written: break + default: throw BenchError("pairing stopped: \(label(flow.state))") + } try BenchFiles.ensureDirectory() try Data(deviceID.utf8).write(to: BenchFiles.lastDevice, options: [.atomic]) timeline.say("paired with \(deviceID)") report(settings) + // On the pairing connection: a new one may not be accepted once the window closes. + if case .editingNetwork = flow.state, let ssid = options["ssid"] { + try await write(ssid, flow, settings) + return + } + // A controller holding a network starts its station once Pair closes the window. + if flow.joinsHeldNetwork { + timeline.say("the controller holds a network: watching it join") + await watchJoin(flow, for: try options.seconds("watch", default: 60)) + } await flow.reset() } @@ -338,11 +359,16 @@ func label(_ state: SetupFlow.State) -> String { func write() async throws { guard let ssid = options["ssid"] else { throw BenchError("write needs --ssid") } + let (flow, settings) = try await resumed() + report(settings) + try await write(ssid, flow, settings) + } + + /// Write `ssid` on the open session, watch the join, then end the session. + func write(_ ssid: String, _ flow: SetupFlow, _ settings: NetworkSettings) async throws { let name = options["passphrase-env"] ?? "ORIGIN89_WIFI_PASSPHRASE" let passphrase = BenchFiles.secret(name, file: "wifi-passphrase") let watchFor = try options.seconds("watch", default: 60) - let (flow, settings) = try await resumed() - report(settings) var draft = NetworkDraft( ssid: ssid, settings: settings, region: Locale.current.region?.identifier) draft.passphrase = passphrase ?? "" @@ -359,18 +385,44 @@ func label(_ state: SetupFlow.State) -> String { await flow.reset() throw BenchError("write stopped") } - await watch(flow, for: watchFor) { flow in + await watchJoin(flow, for: watchFor) + await flow.reset() + } + + /// Follow the join and the move to Wi-Fi until either has an outcome. + func watchJoin(_ flow: SetupFlow, for limit: Duration) async { + await watch(flow, for: limit) { flow in if case .failed = flow.state { return true } switch flow.join { // A join is followed by the switch to Wi-Fi. case .joined: return options.has("bluetooth-only") || (!flow.isSwitchingToWiFi && (flow.link != .bluetooth || flow.wifiUnavailable != nil)) - case .failed, .noAnswer, .connectionLost: return true + case .failed, .noAnswer: return true + // A Wi-Fi search may follow a lost Bluetooth link. + case .connectionLost: return !flow.isSwitchingToWiFi case .idle, .waiting: return false } } timeline.say("final state \(label(flow.state)), join \(flow.join), link \(link(flow))") + } + + /// Leave the controller with no network, as a unit that was never set up. + func clear() async throws { + let (flow, settings) = try await resumed() + report(settings) + guard let country = settings.country, let hostname = settings.hostname else { + await flow.reset() + throw BenchError("not cleared: the controller holds no country or hostname to keep") + } + timeline.say("clearing the network") + await flow.writeNetwork( + NetworkChange(ssid: nil, passphrase: nil, country: country, hostname: hostname)) + timeline.say("state \(label(flow.state))") + guard case .written = flow.state else { + await flow.reset() + throw BenchError("not cleared") + } await flow.reset() } diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index b338c5f..532a4e2 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -35,6 +35,12 @@ import Observation /// Hello and reads its network, whether or not a network was written, until /// the person starts over or the kept enrolment stops working. /// +/// Pairing follows the controller's window: Bluetooth opens only once the +/// person has opened it, since a controller whose station has joined +/// advertises only while the window is open. `Pair` closes the window, and a +/// controller holding a network starts its station then: the flow watches +/// that join instead of asking for a network to write. +/// /// On a controller that reports Wi-Fi (P-216) the flow also reads what its /// radio hears while the network is edited, and watches the join after a /// write. Both run in one background task that every other step stops and @@ -51,6 +57,11 @@ import Observation /// each time whether it is this controller (P-225); an address where Hello /// succeeds is kept. When Wi-Fi cannot be used the session stays on, or falls /// back to, Bluetooth, and `wifiUnavailable` says why. +/// +/// A station starting may drop the Bluetooth link. When that ends the join +/// watch, or takes the answer to a write, the flow looks for the controller +/// on Wi-Fi for up to `wifiLimit` and continues there; a write whose answer +/// was lost counts once the section read over Wi-Fi holds it. @MainActor @Observable public final class SetupFlow { /// After a write, what the radio did with it. public enum JoinWatch: Sendable, Equatable { @@ -68,6 +79,9 @@ import Observation static let scanLimit: Duration = .seconds(20) /// How long a write's join is watched. static let joinLimit: Duration = .seconds(60) + /// How long the flow looks for the controller on Wi-Fi after Bluetooth + /// drops while its station starts. + static let wifiLimit: Duration = .seconds(30) /// The link a session runs over. public enum Link: Sendable, Equatable { @@ -97,6 +111,17 @@ import Observation "The controller did not accept this phone over Wi-Fi, so it stays on Bluetooth." } } + /// Why Wi-Fi failed, for when Bluetooth is gone too. + public var reason: String { + switch self { + case .notReachable: + "This phone could not reach the controller over Wi-Fi. The phone must be on the same network as the controller." + case .localNetworkDenied: + "Origin89 is not allowed to use the local network. Turn on Local Network for Origin89 in Settings, then try again." + case .otherController: "Another controller answered at this controller's address." + case .refused: "The controller did not accept this phone over Wi-Fi." + } + } } /// A WebSocket session: its own transport and client, with the enrolment /// the store keeps. @@ -165,6 +190,12 @@ import Observation if let wifi { return .wifi(address: wifi.address) } return .bluetooth } + /// The controller held a network when it paired, so the flow watches it + /// join instead of writing one. False once the person writes a network. + public var joinsHeldNetwork: Bool { + guard let network, let writtenVersion else { return false } + return network.version == writtenVersion + } /// The network version this session wrote, kept across Time failures. public var writtenVersion: UInt32? { if case .written(let version) = resume { version } else { nil } @@ -190,6 +221,9 @@ import Observation private var resume: Resume = .pair /// Set by a new code: the next connect first looks for a kept enrolment. private var restorePending = false + /// Set by `Pair` until a network read succeeds, across reconnects: a + /// controller holding a network starts its station once the window closes. + private var pairedAwaitingRead = false private var generation = 0 private var isConnecting = false private var deadlineTask: Task? @@ -281,10 +315,13 @@ import Observation ) await closeTransport() await (transport as? any PeerExcludingTransport)?.clearExcludedPeers() + guard generation == operation else { return } if greets, webSocketFactory != nil, let deviceID = controller?.deviceID ?? lastDeviceID { // Active from here, so a suspend during the attempt ends it. transportActive = true + isSwitchingToWiFi = true let opened = await findWiFi(deviceID: deviceID, operation) + isSwitchingToWiFi = false guard generation == operation else { if let opened { await Self.close(opened.link) } return @@ -299,26 +336,35 @@ import Observation transportActive = false SetupLog.flow.notice("Wi-Fi is unavailable: connecting over Bluetooth") } + // Pairing needs the window first; `confirmWindowOpened()` connects. + if resume == .pair, !greets { + state = .openWindow + return + } + guard await openBluetooth(operation) else { return } + isConnecting = false + await openSession(operation) + } + + /// Open the Bluetooth transport. False when the open failed, with the flow + /// failed, or when the flow moved on meanwhile. + private func openBluetooth(_ operation: Int) async -> Bool { + guard let transport else { return false } transportActive = true do { try await transport.open() } catch { // A failed open leaves nothing connected. - if generation == operation { transportActive = false } - guard generation == operation else { return } + guard generation == operation else { return false } + transportActive = false await fail(Self.failure(error)) - return + return false } guard generation == operation else { await closeTransport() - return - } - if resume == .pair, !greets { - state = .openWindow - } else { - isConnecting = false - await openSession(operation) + return false } + return true } public func confirmWindowOpened() async { @@ -332,8 +378,14 @@ import Observation self.state = .failed(.windowClosed, .openWindow) await self.close() } + state = .discovering + // The window is open now, so the controller advertises. + if !transportActive { + isConnecting = true + guard await openBluetooth(operation) else { return } + isConnecting = false + } do { - state = .discovering let discovered = try await discover(client, operation) guard generation == operation else { return } controller = discovered @@ -350,6 +402,7 @@ import Observation deadlineTask?.cancel() rememberController() resume = .readNetwork + pairedAwaitingRead = true state = .greeting let report = try await client.hello() guard generation == operation else { return } @@ -381,11 +434,15 @@ import Observation let discovered = try await discover(client, operation) guard generation == operation else { return } controller = discovered - // A kept enrolment for another epoch (P-222): pair on this connection. + // A kept enrolment for another epoch (P-222): pair once the window is + // open. Opening it can restart the comms module and end this link, so + // `confirmWindowOpened()` connects again. guard await client.isEnrolled() else { guard generation == operation else { return } SetupLog.flow.notice("the kept enrolment is for another epoch: pairing again") keptEnrolmentLost = true + await closeTransport() + guard generation == operation else { return } state = .openWindow return } @@ -455,6 +512,9 @@ import Observation } } + /// Read the network section. The first read after `Pair`, even on a later + /// connection, finds a controller holding a network starting its station: + /// the flow watches that join as if the network were just written. private func readNetwork() async { guard let client = session else { return } let operation = generation @@ -466,7 +526,16 @@ import Observation "network section version \(settings.version, privacy: .public), network held \(settings.ssid != nil, privacy: .public), country held \(settings.country != nil, privacy: .public)" ) network = settings - state = .editingNetwork(settings) + let afterPair = pairedAwaitingRead + pairedAwaitingRead = false + if afterPair, reportsWiFi, settings.ssid != nil, settings.passphraseSet { + SetupLog.flow.info("the controller holds a network: watching its station join") + resume = .written(settings.version) + state = .written(settings.version) + watchJoin(settings.version) + } else { + state = .editingNetwork(settings) + } } catch { guard generation == operation else { return } await fail(error) @@ -490,9 +559,53 @@ import Observation if reportsWiFi, change.ssid != nil { watchJoin(version) } } catch { guard generation == operation else { return } + guard await !confirmOverWiFi(change, after: settings, error) else { return } await fail(error) } } + + /// A write whose answer was lost with the Bluetooth link may have landed: + /// the station it starts can drop the link. Look for the controller on + /// Wi-Fi and read the section there; a newer version holding the SSID + /// written is this write, and the flow continues from it over Wi-Fi. True + /// when the flow went on without the failure: the write was confirmed, or + /// the flow moved on meanwhile. + private func confirmOverWiFi( + _ change: NetworkChange, after settings: NetworkSettings, _ failure: SetupFailure + ) async -> Bool { + guard failure == .connectionDropped || failure == .timedOut, reportsWiFi, + let ssid = change.ssid, wifi == nil, webSocketFactory != nil, + let deviceID = controller?.deviceID + else { return false } + SetupLog.flow.notice("the write's answer was lost: confirming it over Wi-Fi") + // Set first, so a suspend during the close still ends the search. + isSwitchingToWiFi = true + let operation = generation + 1 + await close() + guard generation == operation else { + isSwitchingToWiFi = false + return true + } + guard let opened = await reachWiFi(deviceID: deviceID, operation) else { + return generation != operation + } + guard await adopt(opened, operation) else { return true } + let held: NetworkSettings + do { + held = try await opened.link.client.readNetwork() + } catch { + return generation != operation + } + guard generation == operation else { return true } + guard held.version > settings.version, held.ssid == ssid else { + SetupLog.flow.notice("the section read over Wi-Fi does not hold the write") + return false + } + resume = .written(held.version) + state = .written(held.version) + watchJoin(held.version) + return true + } /// The optional signed Time. Success finishes setup and closes the /// connection. A refusal keeps the session for another try; a lost /// connection, a timeout or a bad reply ends it, and retry reconnects. @@ -619,10 +732,89 @@ import Observation ) // Before closing, so the watch never reads as idle in between. join = .connectionLost + // A station starting may have dropped Bluetooth: look on Wi-Fi. A stop + // leaves the next step to reconnect. Read before `close()` cancels this task. + guard wifi == nil, !Task.isCancelled, webSocketFactory != nil, + let deviceID = controller?.deviceID + else { + await close() + return + } + // Set first, so the search never reads as over in between. + isSwitchingToWiFi = true + let next = generation + 1 await close() + // A suspend or step during the close ends the search before it starts. + guard generation == next else { + isSwitchingToWiFi = false + return + } + join = .waiting + backgroundTask = Task { [weak self] in + await self?.continueOverWiFi(version, deviceID: deviceID, next) + } } } + /// Bluetooth ended during the join watch. Find the controller on Wi-Fi and + /// watch the join there, or leave the join `connectionLost` with the reason + /// in `wifiUnavailable`. + private func continueOverWiFi(_ version: UInt32, deviceID: String, _ operation: Int) async { + let opened = await reachWiFi(deviceID: deviceID, operation) + guard generation == operation else { + if let opened { await Self.close(opened.link) } + return + } + backgroundTask = nil + guard let opened else { + join = .connectionLost + return + } + guard await adopt(opened, operation) else { return } + join = .idle + // Stopped meanwhile: the step that stopped it goes on over Wi-Fi. + if !Task.isCancelled { watchJoin(version) } + } + + /// Look for the controller on Wi-Fi again every `pollInterval`, up to + /// `wifiLimit`, with nothing else open. The link counts as active meanwhile, + /// so a suspend ends the search. Nil when no attempt worked or the flow + /// moved on. + private func reachWiFi(deviceID: String, _ operation: Int) async -> WiFiSession? { + defer { isSwitchingToWiFi = false } + guard generation == operation else { return nil } + transportActive = true + isSwitchingToWiFi = true + wifiUnavailable = nil + let deadline = clock.now + Self.wifiLimit + while generation == operation { + if let opened = await findWiFi(deviceID: deviceID, operation) { return opened } + guard generation == operation, !Task.isCancelled, clock.now < deadline else { break } + do { try await clock.sleep(until: clock.now + Self.pollInterval) } catch { break } + } + if generation == operation { + SetupLog.flow.notice("the controller was not found on Wi-Fi") + transportActive = false + // No candidate answered, or none was found at all. + if wifiUnavailable == nil { wifiUnavailable = .notReachable } + } + return nil + } + + /// Make an opened Wi-Fi session the flow's link. False, with it closed, + /// when the flow moved on. + private func adopt(_ opened: WiFiSession, _ operation: Int) async -> Bool { + guard generation == operation, transportActive else { + await Self.close(opened.link) + return false + } + wifi = opened.link + controller = opened.found + reportsWiFi = opened.report.reportsWiFi + SetupLog.flow.info("the session continues over Wi-Fi") + return true + } + /// Watch the join of the written network again, reconnecting if needed. A /// reconnect that fails in a way a retry would reconnect from returns to /// the written network with no verdict. @@ -684,9 +876,11 @@ import Observation private func switchToWiFi(_ operation: Int) async { guard wifi == nil, transportActive, let deviceID = controller?.deviceID else { return } // Its own task, so stopping the background task does not cut it short. + isSwitchingToWiFi = true let opened = await Task { [weak self] in await self?.findWiFi(deviceID: deviceID, operation) }.value + isSwitchingToWiFi = false guard let opened else { SetupLog.flow.notice("staying on Bluetooth") return @@ -712,8 +906,6 @@ import Observation /// alone. private func findWiFi(deviceID: String, _ operation: Int) async -> WiFiSession? { guard webSocketFactory != nil else { return nil } - isSwitchingToWiFi = true - defer { isSwitchingToWiFi = false } var tried: Set = [] func attempt(_ address: String) async -> WiFiSession? { guard generation == operation, tried.insert(address).inserted else { return nil } @@ -828,8 +1020,9 @@ import Observation /// network stays written, since its step reconnects on its own; anything /// else in progress is suspended and resumes through retry. public func suspend() async { - // A connect still before its open counts too: closing ends it there. - guard transportActive || isConnecting else { return } + // A connect still before its open, or a Wi-Fi search about to start, + // counts too: closing ends it there. + guard transportActive || isConnecting || isSwitchingToWiFi else { return } SetupLog.flow.notice("the app left the foreground: closing the connection") await close() switch state { @@ -902,6 +1095,7 @@ import Observation /// Drop what this flow learned about the controller it last reached. private func clearControllerState() { + pairedAwaitingRead = false controller = nil lastDeviceID = nil network = nil diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift index 87beb50..adaff5c 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift @@ -103,8 +103,10 @@ private struct PeerFactory: ControllerClientFactory { #expect(flow.state == .failed(.controllerMismatch, .connecting)) await flow.retry() #expect(flow.state == .openWindow) - #expect(driver.scans == [[], [wrong], []]) - #expect(driver.peer == wrong) + // Bluetooth opens again once the window is open, excluding nobody. + #expect(driver.scans == [[], [wrong]]) + await flow.confirmWindowOpened() + #expect(Array(driver.scans.prefix(3)) == [[], [wrong], []]) #expect(driver.mostConnections == 1) } @@ -135,6 +137,6 @@ private struct PeerFactory: ControllerClientFactory { #expect(driver.connections == 0) await flow.retry() #expect(flow.state == .openWindow) - #expect(driver.connections == 1) + #expect(driver.connections == 0) #expect(driver.mostConnections == 1) } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift index 1d94d30..ef4e655 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift @@ -41,10 +41,12 @@ final class MemoryEnrolmentStore: EnrolmentStore, @unchecked Sendable { } private actor Transport: FrameTransport { - func open() async throws(TransportError) {} + private(set) var opens = 0 + private(set) var closes = 0 + func open() async throws(TransportError) { opens += 1 } func send(_ frame: Data) async throws(TransportError) {} func receive() async throws(TransportError) -> Data { Data() } - func close() async {} + func close() async { closes += 1 } } /// Holds a credential the way the Rust engine does: the setup code, a kept @@ -142,10 +144,9 @@ private let kept = Data([0x01, 0x55]) private let editing = SetupFlow.State.editingNetwork( NetworkSettings(version: 1, ssid: nil, passphraseSet: false, country: nil, hostname: nil)) -@MainActor private func connected(_ client: KeptClient, _ store: MemoryEnrolmentStore) async throws - -> SetupFlow -{ - let transport = Transport() +@MainActor private func connected( + _ client: KeptClient, _ store: MemoryEnrolmentStore, transport: Transport = Transport() +) async throws -> SetupFlow { let flow = SetupFlow( factory: Factory(client: client), store: store, transportFactory: { transport }, clock: OpenWindowClock()) @@ -177,16 +178,22 @@ private let editing = SetupFlow.State.editingNetwork( #expect(store[KeptClient.deviceID] == KeptClient.paired) } -/// P-222: a kept enrolment for another epoch pairs again on the same -/// connection, and the new enrolment replaces it. +/// P-222: a kept enrolment for another epoch pairs again, and the new +/// enrolment replaces it. Opening the window can restart the comms module, so +/// the link that found out is closed and a new one opens once it is open. @Test @MainActor func aKeptEnrolmentForAnotherEpochPairsAgain() async throws { let client = KeptClient(.otherEpoch) let store = MemoryEnrolmentStore([KeptClient.deviceID: kept]) - let flow = try await connected(client, store) + let transport = Transport() + let flow = try await connected(client, store, transport: transport) #expect(flow.state == .openWindow) #expect(flow.keptEnrolmentLost) #expect(await client.calls == ["discover"]) + #expect(flow.link == nil) + #expect(await transport.opens == 1) + #expect(await transport.closes == 1) await flow.confirmWindowOpened() + #expect(await transport.opens == 2) #expect(flow.state == editing) #expect(await client.calls == ["discover", "discover", "pair", "hello", "read"]) #expect(store[KeptClient.deviceID] == KeptClient.paired) diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift index d4c79af..4f03b66 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift @@ -308,28 +308,40 @@ private struct Factory: ControllerClientFactory { #expect(await client.calls == [.discover, .pair]) } +/// Pairing opens Bluetooth only once the window is open: a controller whose +/// station has joined advertises only then. +@Test @MainActor func bluetoothOpensOnlyOnceTheWindowIsOpen() async throws { + let transport = FakeTransport() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow(FakeClient(), clock: clock, transport: transport) + #expect(await transport.opens == 0) + await flow.confirmWindowOpened() + #expect(await transport.opens == 1) +} + @Test @MainActor func bluetoothUnavailableDuringConnection() async throws { - let flow = SetupFlow( - factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), - transportFactory: { FakeTransport(failure: .unreachable) } - ) - try flow.submitCode("valid") - #expect(flow.state == .connecting) - await flow.connect() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow( + FakeClient(), clock: clock, transport: FakeTransport(failure: .unreachable)) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.bluetoothUnavailable, .connecting)) + // The retry goes back to the window step, still without a connection. await flow.retry() + #expect(flow.state == .openWindow) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.bluetoothUnavailable, .connecting)) } /// A controller was found but its link never became ready: not reported as /// Bluetooth being unavailable. @Test @MainActor func aLinkThatIsNotReadyIsReportedAsSuch() async throws { - let flow = SetupFlow( - factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), - transportFactory: { FakeTransport(failure: .notReady) } - ) - try flow.submitCode("valid") - await flow.connect() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow( + FakeClient(), clock: clock, transport: FakeTransport(failure: .notReady)) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.linkNotReady, .connecting)) } @@ -484,7 +496,7 @@ private struct Factory: ControllerClientFactory { #expect(await transport.closes == 1) await flow.retry() #expect(flow.state == .openWindow) - #expect(await transport.opens == 2) + #expect(await transport.opens == 1) #expect(await transport.mostOpen == 1) clock.finish() } @@ -519,20 +531,18 @@ private struct Factory: ControllerClientFactory { #expect(await transport.mostOpen == 1) } -@Test @MainActor func backgroundWithTheWindowStepSuspendsAndReturnsToIt() async throws { +/// The window step holds no connection, so leaving the app keeps it. +@Test @MainActor func backgroundWithTheWindowStepKeepsIt() async throws { let client = FakeClient() let transport = FakeTransport() let clock = TestClock() defer { clock.finish() } let flow = try await makeFlow(client, clock: clock, transport: transport) await flow.suspend() - #expect(flow.state == .suspended) - #expect(await transport.closes == 1) - await flow.retry() #expect(flow.state == .openWindow) + #expect(await transport.opens == 0) + #expect(await transport.closes == 0) #expect(await client.calls.isEmpty) - #expect(await transport.opens == 2) - #expect(await transport.mostOpen == 1) } @Test @MainActor func backgroundBeforeAConnectOpensSuspendsIt() async throws { @@ -553,7 +563,51 @@ private struct Factory: ControllerClientFactory { #expect(await transport.opens == 0) await flow.retry() #expect(flow.state == .openWindow) - #expect(await transport.opens == 1) + #expect(await transport.opens == 0) +} + +/// Holds `clearExcludedPeers` until released, like a transport still busy. +private actor HeldClearTransport: PeerExcludingTransport { + private var held: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + private(set) var opens = 0 + func open() async throws(TransportError) { opens += 1 } + func send(_ frame: Data) async throws(TransportError) {} + func receive() async throws(TransportError) -> Data { Data() } + func close() async {} + func excludeConnectedPeer() async {} + func clearExcludedPeers() async { + await withCheckedContinuation { + held = $0 + for waiter in waiters { waiter.resume() } + waiters = [] + } + } + func clearing() async { + if held != nil { return } + await withCheckedContinuation { waiters.append($0) } + } + func release() { + held?.resume() + held = nil + } +} + +/// A suspend while the connect clears exclusions stays suspended: the +/// connect does not go on to the window step. +@Test @MainActor func backgroundWhileClearingExclusionsSuspends() async throws { + let transport = HeldClearTransport() + let flow = SetupFlow( + factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), + transportFactory: { transport }, clock: TestClock()) + try flow.submitCode("valid") + let run = Task { await flow.connect() } + await transport.clearing() + await flow.suspend() + await transport.release() + await run.value + #expect(flow.state == .suspended) + #expect(await transport.opens == 0) } @Test @MainActor func backgroundDuringPairSuspendsAndAbandonsIt() async throws { diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift index f96d892..d0a1fa1 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift @@ -43,8 +43,9 @@ private actor WiFiClient: ControllerClient { init( reportsWiFi: Bool = true, scans: [NetworkScan] = [], statuses: [WiFiStatus] = [], + // No network held: after Pair the person chooses one. settings: NetworkSettings = NetworkSettings( - version: 7, ssid: "home", passphraseSet: true, country: "CA", hostname: "unit") + version: 7, ssid: nil, passphraseSet: false, country: "CA", hostname: "unit") ) { self.reportsWiFi = reportsWiFi self.scans = scans @@ -77,8 +78,15 @@ private actor WiFiClient: ControllerClient { if let helloFailure { throw helloFailure } return SessionReport(reportsWiFi: reportsWiFi) } + /// The next read fails with this, once. + var readFailure: SetupFailure? + func failNextRead(with failure: SetupFailure) { readFailure = failure } func readNetwork() async throws(SetupFailure) -> NetworkSettings { calls.append(.read) + if let failure = readFailure { + readFailure = nil + throw failure + } return settings } func writeNetwork(_ change: NetworkChange, expectedVersion: UInt32) async throws(SetupFailure) @@ -547,3 +555,98 @@ private let joined8 = WiFiStatus( #expect(!NetworkSecurity.open.isJoinable) #expect(!NetworkSecurity.other.isJoinable) } + +// MARK: - A controller that holds a network when it pairs + +private let home = NetworkSettings( + version: 7, ssid: "home", passphraseSet: true, country: "CA", hostname: "unit") + +/// Pair closes the window and the station joins the network the controller +/// holds: the flow watches that join and writes nothing. +@Test @MainActor func pairingAControllerThatHoldsANetworkWatchesItJoin() async throws { + let joined7 = WiFiStatus(section: 7, radio: (version: 7, state: .joined(address: "10.0.0.9"))) + let client = WiFiClient(statuses: [joined7], settings: home) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .written(7)) + #expect(flow.joinsHeldNetwork) + await settle { flow.join != .waiting } + #expect(flow.join == .joined(address: "10.0.0.9")) + #expect(await client.calls == [.discover, .pair, .hello, .read, .status]) + // The person may still choose another network. + await flow.changeNetwork() + #expect(flow.state == .editingNetwork(home)) +} + +/// Without Wi-Fi status reads there is no join to watch, and a network held +/// without its passphrase cannot be joined: both go to the network form. +@Test(arguments: [ + ( + true, + NetworkSettings(version: 7, ssid: "home", passphraseSet: false, country: "CA", hostname: "unit") + ), + (false, home), +]) +@MainActor func aHeldNetworkThatCannotBeWatchedGoesToTheForm( + reportsWiFi: Bool, settings: NetworkSettings +) async throws { + let client = WiFiClient(reportsWiFi: reportsWiFi, settings: settings) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .editingNetwork(settings)) + #expect(!flow.joinsHeldNetwork) + #expect(flow.join == .idle) +} + +/// Once the person writes a network, the written version is theirs. +@Test @MainActor func writingANetworkIsNotTheHeldOne() async throws { + let client = WiFiClient(statuses: [joining8]) + let clock = PollClock() + defer { clock.finish() } + let (flow, _) = try await editing(client, clock) + #expect(!flow.joinsHeldNetwork) + await flow.writeNetwork( + NetworkChange(ssid: "cabin", passphrase: "correct horse", country: "CA", hostname: "unit")) + #expect(flow.state == .written(8)) + #expect(!flow.joinsHeldNetwork) +} + +/// The link drops between Pair and the first read: the read on the next +/// connection still finds the network held since pairing, and watches it. +@Test @MainActor func aHeldNetworkReadAfterAReconnectIsStillWatched() async throws { + let joined7 = WiFiStatus(section: 7, radio: (version: 7, state: .joined(address: "10.0.0.9"))) + let client = WiFiClient(statuses: [joined7], settings: home) + await client.failNextRead(with: .connectionDropped) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .failed(.connectionDropped, .connecting)) + await flow.retry() + #expect(flow.state == .written(7)) + #expect(flow.joinsHeldNetwork) + await settle { flow.join != .waiting } + #expect(flow.join == .joined(address: "10.0.0.9")) + #expect( + await client.calls == [.discover, .pair, .hello, .read, .discover, .hello, .read, .status]) + + // Only the first read after Pair: reading again goes to the form. + await flow.changeNetwork() + #expect(flow.state == .editingNetwork(home)) +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift index 3b4b80b..8584428 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift @@ -31,12 +31,19 @@ private actor LinkClient: ControllerClient { ControllerSummary(deviceID: deviceID)) var helloFailure: SetupFailure? var timeFailure: SetupFailure? + var statusFailure: SetupFailure? + var writeFailure: SetupFailure? var joinedAt: String? + /// The section `readNetwork` answers. + var held = settings init(joinedAt: String? = nil) { self.joinedAt = joinedAt } func answerDiscover(_ result: Result) { discovered = result } func failHello(with failure: SetupFailure?) { helloFailure = failure } func failTime(with failure: SetupFailure?) { timeFailure = failure } + func failStatus(with failure: SetupFailure?) { statusFailure = failure } + func failWrite(with failure: SetupFailure?) { writeFailure = failure } + func hold(_ settings: NetworkSettings) { held = settings } func restore(from store: any EnrolmentStore) async {} func isEnrolled() async -> Bool { true } func keep(in store: any EnrolmentStore) async throws {} @@ -52,13 +59,14 @@ private actor LinkClient: ControllerClient { } func readNetwork() async throws(SetupFailure) -> NetworkSettings { calls.append(.read) - return settings + return held } func scanWiFi(refresh: Bool) async throws(SetupFailure) -> NetworkScan { NetworkScan(progress: .none, networks: nil) } func wifiStatus() async throws(SetupFailure) -> WiFiStatus { calls.append(.status) + if let statusFailure { throw statusFailure } return WiFiStatus( section: 8, radio: (version: 8, state: joinedAt.map { .joined(address: $0) } ?? .joining)) } @@ -66,6 +74,7 @@ private actor LinkClient: ControllerClient { -> UInt32 { calls.append(.write) + if let writeFailure { throw writeFailure } return expectedVersion + 1 } func setTime(_ date: Date) async throws(SetupFailure) { @@ -129,6 +138,16 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { } } +/// DNS-SD answers that a test changes as the controller comes and goes. +@MainActor private final class FakeBrowser: ControllerBrowser { + var found: [String] = [] + private(set) var browses = 0 + func addresses(advertising deviceID: String) async -> [String] { + browses += 1 + return found + } +} + @MainActor private struct Bench { let flow: SetupFlow let bluetooth: LinkTransport @@ -145,7 +164,7 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { /// A launch that reconnects to the controller from its kept enrolment. @MainActor private func launch( bluetooth: LinkClient = LinkClient(joinedAt: address), wifi: LinkClient? = LinkClient(), - addresses: MemoryAddresses = MemoryAddresses() + addresses: MemoryAddresses = MemoryAddresses(), browser: FakeBrowser? = nil ) -> Bench { let bluetoothTransport = LinkTransport(.bluetooth) let webSocket = LinkTransport(.wifi) @@ -158,7 +177,7 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { dialled.addresses.append(address) return webSocket }, - addresses: addresses) + addresses: addresses, browser: browser) return Bench( flow: flow, bluetooth: bluetoothTransport, webSocket: webSocket, factory: factory, addresses: addresses, dialled: dialled) @@ -349,3 +368,157 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { #expect(await bench.webSocket.closes == opens) #expect(bench.flow.state == .written(8)) } + +// MARK: - Bluetooth dropping while the station starts + +/// Connect over Bluetooth with the controller not yet on the network. +@MainActor private func editingOverBluetooth(_ bench: Bench) async { + await bench.flow.connect() + #expect(bench.flow.state == .editingNetwork(settings)) + #expect(bench.flow.link == .bluetooth) +} + +/// The station starting drops Bluetooth before the join is reported: the +/// flow finds the controller over DNS-SD and reads the join over Wi-Fi. +@Test @MainActor func aBluetoothDropDuringTheJoinContinuesOverWiFi() async throws { + let bluetooth = LinkClient() + let wifi = LinkClient(joinedAt: address) + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .joined(address: address) } + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.link == .wifi(address: address)) + #expect(!bench.flow.isSwitchingToWiFi) + #expect(await bench.bluetooth.closes == 1) + #expect(await wifi.calls == [.discover, .hello, .status]) + #expect(bench.addresses.load(deviceID: deviceID) == address) +} + +/// With no candidate at all, the search still ends with a reason. +@Test @MainActor func aSearchWithNoCandidateReportsTheControllerUnreachable() async throws { + let bluetooth = LinkClient() + let bench = launch(bluetooth: bluetooth, browser: FakeBrowser()) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .connectionLost && !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.wifiUnavailable == .notReachable) + #expect(bench.flow.link == nil) + #expect(await bench.webSocket.opens == 0) +} + +/// Starting over as the search is scheduled leaves nothing open. +@Test @MainActor func resettingBeforeTheSearchStartsLeavesNothingOpen() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .waiting && bench.flow.isSwitchingToWiFi } + await bench.flow.reset() + for _ in 0..<100 { await Task.yield() } + #expect(bench.flow.state == .enterCode) + #expect(bench.flow.link == nil) + #expect(!bench.flow.isSwitchingToWiFi) + let opens = await bench.webSocket.opens + #expect(await bench.webSocket.closes == opens) +} + +/// The controller is looked for again until `wifiLimit`, then the join is +/// reported lost with the reason, and a check tries again. +@Test @MainActor func aControllerNotFoundOnWiFiIsReportedAfterTheLimit() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.webSocket.failOpens(with: .unreachable) + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .connectionLost && !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.link == nil) + #expect(bench.flow.wifiUnavailable == .notReachable) + // After the connect's browse, one at 0 s and one every 2 s up to the 30 s limit. + #expect(browser.browses == 1 + 16) + #expect(await bench.webSocket.opens == 16) + #expect(await bench.webSocket.closes == 0) +} + +/// Leaving the app during the search ends it with nothing open. +@Test @MainActor func suspendingDuringTheSearchEndsIt() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + await bench.webSocket.failOpens(with: .unreachable) + await bench.flow.writeNetwork(change) + await settle { bench.flow.isSwitchingToWiFi } + await bench.flow.suspend() + await settle { !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.link == nil) + #expect(bench.flow.state == .written(8)) + let browses = browser.browses + for _ in 0..<100 { await Task.yield() } + #expect(browser.browses == browses) +} + +/// A write whose answer was lost with Bluetooth landed when the section read +/// over Wi-Fi holds it: setup continues from it there. +@Test @MainActor func aLostWriteAnswerIsConfirmedOverWiFi() async throws { + let bluetooth = LinkClient() + let wifi = LinkClient(joinedAt: address) + await wifi.hold( + NetworkSettings(version: 8, ssid: "cabin", passphraseSet: true, country: "CA", hostname: "unit") + ) + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.writtenVersion == 8) + #expect(bench.flow.link == .wifi(address: address)) + await settle { bench.flow.join == .joined(address: address) } + #expect(await wifi.calls == [.discover, .hello, .read, .status]) +} + +/// Over Wi-Fi the section still holds the old network, or the controller is +/// not found: the write did not land as far as this phone knows. +@Test(arguments: [true, false]) +@MainActor func anUnconfirmedLostWriteFails(found: Bool) async throws { + let bluetooth = LinkClient() + let wifi = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .connectionDropped) + if found { browser.found = [address] } + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .failed(.connectionDropped, .connecting)) + #expect(bench.flow.writtenVersion == nil) + #expect(bench.flow.link == nil) + #expect(await bench.webSocket.closes == (found ? 1 : 0)) + #expect(await wifi.calls == (found ? [.discover, .hello, .read] : [])) +} + +/// A refusal is the controller's answer, not a lost one: nothing to confirm. +@Test @MainActor func aRefusedWriteIsNotLookedForOverWiFi() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .staleVersion) + browser.found = [address] + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .failed(.staleVersion, .readingNetwork)) + #expect(browser.browses == 1) +}