From 4825bb52c4b287df76757a4a8acfb27150095802 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sun, 15 Feb 2026 16:05:49 +0000 Subject: [PATCH] iOS: stabilize QR onboarding pairing --- .../Gateway/GatewayTrustPromptAlert.swift | 9 +- apps/ios/Sources/Model/NodeAppModel.swift | 59 +++++++++ .../Onboarding/OnboardingWizardView.swift | 123 ++++++++++++++++-- apps/ios/Sources/RootCanvas.swift | 3 +- apps/ios/Tests/DeepLinkParserTests.swift | 18 +++ .../GatewayConnectionControllerTests.swift | 13 +- .../Sources/OpenClawKit/DeepLinks.swift | 4 +- .../Sources/OpenClawKit/GatewayChannel.swift | 39 +++++- 8 files changed, 237 insertions(+), 31 deletions(-) diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift index f117ad9ea46..eff6b71bad5 100644 --- a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift +++ b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift @@ -6,10 +6,10 @@ struct GatewayTrustPromptAlert: ViewModifier { private var promptBinding: Binding { Binding( get: { self.gatewayController.pendingTrustPrompt }, - set: { newValue in - if newValue == nil { - self.gatewayController.clearPendingTrustPrompt() - } + set: { _ in + // Keep pending trust state until explicit user action. + // `alert(item:)` may set the binding to nil during dismissal, which can race with + // the button handler and cause accept to no-op. }) } @@ -39,4 +39,3 @@ extension View { self.modifier(GatewayTrustPromptAlert()) } } - diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index a398f793c9c..c3afc01fcc1 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -57,6 +57,11 @@ final class NodeAppModel { var gatewayRemoteAddress: String? var connectedGatewayID: String? var gatewayAutoReconnectEnabled: Bool = true + // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX. + // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate + // multiple pending requests and cause the onboarding UI to "flip-flop". + var gatewayPairingPaused: Bool = false + var gatewayPairingRequestId: String? var seamColorHex: String? private var mainSessionBaseKey: String = "main" var selectedAgentId: String? @@ -1547,6 +1552,8 @@ extension NodeAppModel { func disconnectGateway() { self.gatewayAutoReconnectEnabled = false + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -1576,6 +1583,8 @@ extension NodeAppModel { private extension NodeAppModel { func prepareForGatewayConnect(url: URL, stableID: String) { self.gatewayAutoReconnectEnabled = true + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil self.nodeGatewayTask?.cancel() self.operatorGatewayTask?.cancel() self.gatewayHealthMonitor.stop() @@ -1605,6 +1614,10 @@ private extension NodeAppModel { guard let self else { return } var attempt = 0 while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } if await self.isOperatorConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1680,8 +1693,13 @@ private extension NodeAppModel { var attempt = 0 var currentOptions = nodeOptions var didFallbackClientId = false + var pausedForPairingApproval = false while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } if await self.isGatewayConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1768,11 +1786,52 @@ private extension NodeAppModel { self.showLocalCanvasOnDisconnect() } GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)") + + // If pairing is required, stop reconnect churn. The user must approve the request + // on the gateway before another connect attempt will succeed, and retry loops can + // generate multiple pending requests. + let lower = error.localizedDescription.lowercased() + if lower.contains("not_paired") || lower.contains("pairing required") { + let requestId: String? = { + // GatewayResponseError for connect decorates the message with `(requestId: ...)`. + // Keep this resilient since other layers may wrap the text. + let text = error.localizedDescription + guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil } + guard let end = text[start...].firstIndex(of: ")") else { return nil } + let raw = String(text[start..? @State private var showQRScanner: Bool = false @State private var scannerError: String? @@ -131,6 +135,7 @@ struct OnboardingWizardView: View { } } } + .gatewayTrustPromptAlert() .alert("QR Scanner Unavailable", isPresented: Binding( get: { self.scannerError != nil }, set: { if !$0 { self.scannerError = nil } } @@ -147,6 +152,7 @@ struct OnboardingWizardView: View { }, onError: { error in self.showQRScanner = false + self.statusLine = "Scanner error: \(error)" self.scannerError = error }, onDismiss: { @@ -203,6 +209,24 @@ struct OnboardingWizardView: View { .onChange(of: self.discoveryDomain) { _, _ in self.scheduleDiscoveryRestart() } + .onChange(of: self.manualPortText) { _, newValue in + let digits = newValue.filter(\.isNumber) + if digits != newValue { + self.manualPortText = digits + return + } + guard let parsed = Int(digits), parsed > 0 else { + self.manualPort = 0 + return + } + self.manualPort = min(parsed, 65535) + } + .onChange(of: self.manualPort) { _, newValue in + let normalized = newValue > 0 ? String(newValue) : "" + if self.manualPortText != normalized { + self.manualPortText = normalized + } + } .onChange(of: self.gatewayToken) { _, newValue in self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) } @@ -211,21 +235,36 @@ struct OnboardingWizardView: View { } .onChange(of: self.appModel.gatewayStatusText) { _, newValue in let next = GatewayConnectionIssue.detect(from: newValue) - self.issue = next - if self.step == .connect && (next.needsAuthToken || next.needsPairing) { + // Avoid "flip-flopping" the UI by clearing pairing info when the underlying connection + // transitions through intermediate statuses (e.g. Offline after we pause reconnects). + if self.issue.needsPairing, next.needsPairing { + // Keep the requestId sticky even if the status line omits it after we pause. + let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId + self.issue = .pairingRequired(requestId: mergedRequestId) + } else if self.issue.needsPairing, !next.needsPairing { + // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect. + } else { + self.issue = next + } + if let requestId = next.requestId, !requestId.isEmpty { + self.pairingRequestId = requestId + } + if next.needsAuthToken || next.needsPairing { self.step = .auth } if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.connectMessage = newValue + self.statusLine = newValue } } .onChange(of: self.appModel.gatewayServerName) { _, newValue in guard newValue != nil else { return } - self.step = .success + self.statusLine = "Connected." if !self.didMarkCompleted, let selectedMode { OnboardingStateStore.markCompleted(mode: selectedMode) self.didMarkCompleted = true } + self.onClose() } } @@ -253,6 +292,7 @@ struct OnboardingWizardView: View { VStack(spacing: 12) { Button { + self.statusLine = "Opening QR scanner…" self.showQRScanner = true } label: { Label("Scan QR Code", systemImage: "qrcode") @@ -270,6 +310,13 @@ struct OnboardingWizardView: View { .buttonStyle(.bordered) .controlSize(.large) } + .padding(.bottom, 12) + + Text(self.statusLine) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) .padding(.horizontal, 24) .padding(.bottom, 48) } @@ -331,6 +378,7 @@ struct OnboardingWizardView: View { LabeledContent("Mode", value: selectedMode.title) LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Progress", value: self.statusLine) } header: { Text("Status") } footer: { @@ -413,7 +461,7 @@ struct OnboardingWizardView: View { TextField("Host", text: self.$manualHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() - TextField("Port", value: self.$manualPort, format: .number) + TextField("Port", text: self.$manualPortText) .keyboardType(.numberPad) Toggle("Use TLS", isOn: self.$manualTLS) @@ -441,13 +489,19 @@ struct OnboardingWizardView: View { private var authStep: some View { Group { Section("Authentication") { + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Gateway Password", text: self.$gatewayPassword) + if self.issue.needsAuthToken { - TextField("Gateway Auth Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - SecureField("Gateway Password", text: self.$gatewayPassword) + Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.") + .font(.footnote) + .foregroundStyle(.secondary) } else { Text("Auth token looks valid.") + .font(.footnote) + .foregroundStyle(.secondary) } } @@ -469,7 +523,7 @@ struct OnboardingWizardView: View { } header: { Text("Pairing Approval") } footer: { - Text("Run these commands on your gateway host to approve this device.") + Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.") } } @@ -485,6 +539,20 @@ struct OnboardingWizardView: View { } } .disabled(self.connectingGatewayID != nil) + + Button { + self.resumeAfterPairingApproval() + } label: { + Label("Resume After Approval", systemImage: "arrow.clockwise") + } + .disabled(self.connectingGatewayID != nil || !self.issue.needsPairing) + + Button { + self.openQRScannerFromOnboarding() + } label: { + Label("Scan QR Code Again", systemImage: "qrcode.viewfinder") + } + .disabled(self.connectingGatewayID != nil) } } } @@ -535,7 +603,7 @@ struct OnboardingWizardView: View { TextField("Host", text: self.$manualHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() - TextField("Port", value: self.$manualPort, format: .number) + TextField("Port", text: self.$manualPortText) .keyboardType(.numberPad) Toggle("Use TLS", isOn: self.$manualTLS) TextField("Discovery Domain (optional)", text: self.$discoveryDomain) @@ -569,15 +637,35 @@ struct OnboardingWizardView: View { if let password = link.password { self.gatewayPassword = password } + self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) self.showQRScanner = false self.connectMessage = "Connecting via QR code…" - self.step = .connect + self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" if self.selectedMode == nil { self.selectedMode = link.tls ? .remoteDomain : .homeNetwork } Task { await self.connectManual() } } + private func openQRScannerFromOnboarding() { + // Stop active reconnect loops before scanning new credentials. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.connectMessage = nil + self.issue = .none + self.pairingRequestId = nil + self.statusLine = "Opening QR scanner…" + self.showQRScanner = true + } + + private func resumeAfterPairingApproval() { + // We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests. + self.appModel.gatewayAutoReconnectEnabled = true + self.connectMessage = "Retrying after approval…" + self.statusLine = "Retrying after approval…" + Task { await self.retryLastAttempt() } + } + private func detectQRCode(from data: Data) -> String? { guard let ciImage = CIImage(data: data) else { return nil } let detector = CIDetector( @@ -622,6 +710,7 @@ struct OnboardingWizardView: View { self.manualTLS = true } } + self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : "" if self.selectedMode == nil { self.selectedMode = OnboardingStateStore.lastMode() } @@ -635,6 +724,15 @@ struct OnboardingWizardView: View { self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" } + + let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil + let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { + self.didAutoPresentQR = true + self.statusLine = "No saved pairing found. Scan QR code to connect." + self.showQRScanner = true + } } private func scheduleDiscoveryRestart() { @@ -658,6 +756,7 @@ struct OnboardingWizardView: View { private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { self.connectingGatewayID = gateway.id self.connectMessage = "Connecting to \(gateway.name)…" + self.statusLine = "Connecting to \(gateway.name)…" defer { self.connectingGatewayID = nil } await self.gatewayController.connect(gateway) } @@ -699,6 +798,7 @@ struct OnboardingWizardView: View { guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } self.connectingGatewayID = "manual" self.connectMessage = "Connecting to \(host)…" + self.statusLine = "Connecting to \(host):\(self.manualPort)…" defer { self.connectingGatewayID = nil } await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS) } @@ -706,6 +806,7 @@ struct OnboardingWizardView: View { private func retryLastAttempt() async { self.connectingGatewayID = "retry" self.connectMessage = "Retrying…" + self.statusLine = "Retrying last connection…" defer { self.connectingGatewayID = nil } await self.gatewayController.connectLastKnown() } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 2801d726dc9..ebbf36a3444 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -86,10 +86,10 @@ struct RootCanvas: View { .environment(self.gatewayController) } .onAppear { self.updateIdleTimer() } + .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } - .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeShowQuickSetup() } .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } @@ -196,6 +196,7 @@ struct RootCanvas: View { private func maybeAutoOpenSettings() { guard !self.didAutoOpenSettings else { return } + guard !self.showOnboarding else { return } guard self.shouldAutoOpenSettings() else { return } self.didAutoOpenSettings = true self.presentedSheet = .settings diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 23b21c394c4..ea8b2a81203 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -106,4 +106,22 @@ import Testing @Test func parseGatewaySetupCodeRejectsInvalidInput() { #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) } + + @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { + let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: nil)) + } } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 6c2046f0817..27e7aed7aea 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -101,16 +101,9 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> host: "gateway.example.com", port: 443, useTLS: true, - stableID: "manual:gateway.example.com:443") + stableID: "manual|gateway.example.com|443") let loaded = GatewaySettingsStore.loadLastGatewayConnection() - guard case let .manual(host, port, useTLS, stableID) = loaded else { - Issue.record("Expected manual last-gateway connection") - return - } - #expect(host == "gateway.example.com") - #expect(port == 443) - #expect(useTLS == true) - #expect(stableID == "manual:gateway.example.com:443") + #expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443")) } } @@ -120,7 +113,7 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> "gateway.last.host": "", "gateway.last.port": 0, "gateway.last.tls": false, - "gateway.last.stableID": "manual:bad:0", + "gateway.last.stableID": "manual|invalid|0", ]) { let loaded = GatewaySettingsStore.loadLastGatewayConnection() #expect(loaded == nil) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 52122712bba..30606ca2671 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -34,9 +34,9 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { let hostname = parsed.host, !hostname.isEmpty else { return nil } - let scheme = parsed.scheme ?? "ws" + let scheme = (parsed.scheme ?? "ws").lowercased() let tls = scheme == "wss" - let port = parsed.port ?? 18789 + let port = parsed.port ?? (tls ? 443 : 18789) let token = json["token"] as? String let password = json["password"] as? String return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index a255fc7a81d..9682a31aa46 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -133,10 +133,16 @@ public actor GatewayChannelActor { private var lastAuthSource: GatewayAuthSource = .none private let decoder = JSONDecoder() private let encoder = JSONEncoder() - private let connectTimeoutSeconds: Double = 6 - private let connectChallengeTimeoutSeconds: Double = 3.0 + // Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event, + // and we must include the nonce once the gateway requires v2 signing. + private let connectTimeoutSeconds: Double = 12 + private let connectChallengeTimeoutSeconds: Double = 6.0 + // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, + // but NATs/proxies often require outbound traffic to keep the connection alive. + private let keepaliveIntervalSeconds: Double = 15.0 private var watchdogTask: Task? private var tickTask: Task? + private var keepaliveTask: Task? private let defaultRequestTimeoutMs: Double = 15000 private let pushHandler: (@Sendable (GatewayPush) async -> Void)? private let connectOptions: GatewayConnectOptions? @@ -175,6 +181,9 @@ public actor GatewayChannelActor { self.tickTask?.cancel() self.tickTask = nil + self.keepaliveTask?.cancel() + self.keepaliveTask = nil + self.task?.cancel(with: .goingAway, reason: nil) self.task = nil @@ -257,6 +266,7 @@ public actor GatewayChannelActor { self.connected = true self.backoffMs = 500 self.lastSeq = nil + self.startKeepalive() let waiters = self.connectWaiters self.connectWaiters.removeAll() @@ -265,6 +275,29 @@ public actor GatewayChannelActor { } } + private func startKeepalive() { + self.keepaliveTask?.cancel() + self.keepaliveTask = Task { [weak self] in + guard let self else { return } + await self.keepaliveLoop() + } + } + + private func keepaliveLoop() async { + while self.shouldReconnect { + try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000)) + guard self.shouldReconnect else { return } + guard self.connected else { continue } + // Best-effort outbound message to keep intermediate NAT/proxy state alive. + // We intentionally ignore the response. + do { + try await self.send(method: "health", params: nil) + } catch { + // Avoid spamming logs; the reconnect paths will surface meaningful errors. + } + } + } + private func sendConnect() async throws { let platform = InstanceIdentity.platformString let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier @@ -458,6 +491,8 @@ public actor GatewayChannelActor { let wrapped = self.wrap(err, context: "gateway receive") self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") self.connected = false + self.keepaliveTask?.cancel() + self.keepaliveTask = nil await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)") await self.failPending(wrapped) await self.scheduleReconnect()