diff --git a/CHANGELOG.md b/CHANGELOG.md index 453b2cb94be..9f7c5a236a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai - Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) thanks @100yenadmin. - Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier. - Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras. +- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman. ## 2026.4.5 diff --git a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift index 56d490e226b..2a790431582 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit enum GatewayConnectionIssue: Equatable { case none @@ -29,6 +30,37 @@ enum GatewayConnectionIssue: Equatable { return false } + static func detect(problem: GatewayConnectionProblem?) -> Self { + guard let problem else { return .none } + if problem.needsPairingApproval { + return .pairingRequired(requestId: problem.requestId) + } + if problem.needsCredentialUpdate { + return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized + } + switch problem.kind { + case .deviceIdentityRequired, + .deviceSignatureExpired, + .deviceNonceRequired, + .deviceNonceMismatch, + .deviceSignatureInvalid, + .devicePublicKeyInvalid, + .deviceIdMismatch, + .tailscaleIdentityMissing, + .tailscaleProxyMissing, + .tailscaleWhoisFailed, + .tailscaleIdentityMismatch, + .authRateLimited: + return .unauthorized + case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: + return .network + case .unknown: + return .unknown(problem.message) + default: + return .none + } + } + static func detect(from statusText: String) -> Self { let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .none } diff --git a/apps/ios/Sources/Gateway/GatewayProblemView.swift b/apps/ios/Sources/Gateway/GatewayProblemView.swift new file mode 100644 index 00000000000..b71676668bb --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayProblemView.swift @@ -0,0 +1,232 @@ +import OpenClawKit +import SwiftUI +import UIKit + +struct GatewayProblemBanner: View { + let problem: GatewayConnectionProblem + var primaryActionTitle: String? + var onPrimaryAction: (() -> Void)? + var onShowDetails: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: self.iconName) + .font(.headline.weight(.semibold)) + .foregroundStyle(self.tint) + .frame(width: 20) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.problem.title) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + Text(self.ownerLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + Text(self.problem.message) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let requestId = self.problem.requestId { + Text("Request ID: \(requestId)") + .font(.system(.caption, design: .monospaced).weight(.medium)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + HStack(spacing: 10) { + if let primaryActionTitle, let onPrimaryAction { + Button(primaryActionTitle, action: onPrimaryAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + if let onShowDetails { + Button("Details", action: onShowDetails) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + .thinMaterial, + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + } + + private var iconName: String { + switch self.problem.kind { + case .pairingRequired, + .pairingRoleUpgradeRequired, + .pairingScopeUpgradeRequired, + .pairingMetadataUpgradeRequired: + return "person.crop.circle.badge.clock" + case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: + return "wifi.exclamationmark" + case .deviceIdentityRequired, + .deviceSignatureExpired, + .deviceNonceRequired, + .deviceNonceMismatch, + .deviceSignatureInvalid, + .devicePublicKeyInvalid, + .deviceIdMismatch: + return "lock.shield" + default: + return "exclamationmark.triangle.fill" + } + } + + private var tint: Color { + switch self.problem.kind { + case .pairingRequired, + .pairingRoleUpgradeRequired, + .pairingScopeUpgradeRequired, + .pairingMetadataUpgradeRequired: + return .orange + case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: + return .yellow + default: + return .red + } + } + + private var ownerLabel: String { + switch self.problem.owner { + case .gateway: + return "Fix on gateway" + case .iphone: + return "Fix on iPhone" + case .both: + return "Check both" + case .network: + return "Check network" + case .unknown: + return "Needs attention" + } + } +} + +struct GatewayProblemDetailsSheet: View { + @Environment(\.dismiss) private var dismiss + + let problem: GatewayConnectionProblem + var primaryActionTitle: String? + var onPrimaryAction: (() -> Void)? + + @State private var copyFeedback: String? + + var body: some View { + NavigationStack { + List { + Section { + VStack(alignment: .leading, spacing: 10) { + Text(self.problem.title) + .font(.title3.weight(.semibold)) + Text(self.problem.message) + .font(.body) + .foregroundStyle(.secondary) + Text(self.ownerSummary) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + if let requestId = self.problem.requestId { + Section("Request") { + Text(verbatim: requestId) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Button("Copy request ID") { + UIPasteboard.general.string = requestId + self.copyFeedback = "Copied request ID" + } + } + } + + if let actionCommand = self.problem.actionCommand { + Section("Gateway command") { + Text(verbatim: actionCommand) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Button("Copy command") { + UIPasteboard.general.string = actionCommand + self.copyFeedback = "Copied command" + } + } + } + + if let docsURL = self.problem.docsURL { + Section("Help") { + Link(destination: docsURL) { + Label("Open docs", systemImage: "book") + } + Text(verbatim: docsURL.absoluteString) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + if let technicalDetails = self.problem.technicalDetails { + Section("Technical details") { + Text(verbatim: technicalDetails) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + if let copyFeedback { + Section { + Text(copyFeedback) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Connection problem") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if let primaryActionTitle, let onPrimaryAction { + Button(primaryActionTitle) { + self.dismiss() + onPrimaryAction() + } + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + self.dismiss() + } + } + } + } + } + + private var ownerSummary: String { + switch self.problem.owner { + case .gateway: + return "Primary fix: gateway" + case .iphone: + return "Primary fix: this iPhone" + case .both: + return "Primary fix: check both this iPhone and the gateway" + case .network: + return "Primary fix: network or remote access" + case .unknown: + return "Primary fix: review details and retry" + } + } +} diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift index eac92df71e8..c8b4db0aec5 100644 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -8,6 +8,7 @@ struct GatewayQuickSetupSheet: View { @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false @State private var connecting: Bool = false @State private var connectError: String? + @State private var showGatewayProblemDetails: Bool = false var body: some View { NavigationStack { @@ -15,6 +16,14 @@ struct GatewayQuickSetupSheet: View { Text("Connect to a Gateway?") .font(.title2.bold()) + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemBanner( + problem: gatewayProblem, + onShowDetails: { + self.showGatewayProblemDetails = true + }) + } + if let candidate = self.bestCandidate { VStack(alignment: .leading, spacing: 6) { Text(verbatim: candidate.name) @@ -27,7 +36,7 @@ struct GatewayQuickSetupSheet: View { // Use verbatim strings so Bonjour-provided values can't be interpreted as // localized format strings (which can crash with Objective-C exceptions). Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)") - Text(verbatim: "Status: \(self.appModel.gatewayStatusText)") + Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)") Text(verbatim: "Node: \(self.appModel.nodeStatusText)") Text(verbatim: "Operator: \(self.appModel.operatorStatusText)") } @@ -104,6 +113,11 @@ struct GatewayQuickSetupSheet: View { } } } + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemDetailsSheet(problem: gatewayProblem) + } + } } private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? { diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 989ecc9325a..1935976e6c6 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -120,6 +120,10 @@ final class NodeAppModel { // multiple pending requests and cause the onboarding UI to "flip-flop". var gatewayPairingPaused: Bool = false var gatewayPairingRequestId: String? + private(set) var lastGatewayProblem: GatewayConnectionProblem? + var gatewayDisplayStatusText: String { + self.lastGatewayProblem?.statusText ?? self.gatewayStatusText + } var seamColorHex: String? private var mainSessionBaseKey: String = "main" var selectedAgentId: String? @@ -1815,6 +1819,7 @@ extension NodeAppModel { self.gatewayAutoReconnectEnabled = false self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil + self.lastGatewayProblem = nil self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -1848,6 +1853,7 @@ private extension NodeAppModel { self.gatewayAutoReconnectEnabled = true self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil + self.lastGatewayProblem = nil self.nodeGatewayTask?.cancel() self.operatorGatewayTask?.cancel() self.gatewayHealthMonitor.stop() @@ -1866,6 +1872,38 @@ private extension NodeAppModel { self.apnsLastRegisteredTokenHex = nil } + func clearGatewayConnectionProblem() { + self.lastGatewayProblem = nil + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + } + + func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + self.lastGatewayProblem = problem + self.gatewayStatusText = problem.statusText + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.gatewayConnected = false + self.showLocalCanvasOnDisconnect() + if problem.pauseReconnect { + self.gatewayAutoReconnectEnabled = false + } + if problem.needsPairingApproval { + self.gatewayPairingPaused = true + self.gatewayPairingRequestId = problem.requestId + } else { + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + } + } + + func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool { + guard let lastGatewayProblem else { return false } + return GatewayConnectionProblemMapper.shouldPreserve( + previousProblem: lastGatewayProblem, + overDisconnectReason: reason) + } + func shouldStartOperatorGatewayLoop( token: String?, bootstrapToken: String?, @@ -2162,6 +2200,7 @@ private extension NodeAppModel { onConnected: { [weak self] in guard let self else { return } await MainActor.run { + self.clearGatewayConnectionProblem() self.gatewayStatusText = "Connected" self.gatewayServerName = url.host ?? "gateway" self.gatewayConnected = true @@ -2218,7 +2257,13 @@ private extension NodeAppModel { onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { - self.gatewayStatusText = "Disconnected: \(reason)" + if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason), + let lastGatewayProblem = self.lastGatewayProblem + { + self.gatewayStatusText = lastGatewayProblem.statusText + } else { + self.gatewayStatusText = "Disconnected: \(reason)" + } self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.gatewayConnected = false @@ -2257,50 +2302,25 @@ private extension NodeAppModel { } attempt += 1 - await MainActor.run { - self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.gatewayConnected = false - self.showLocalCanvasOnDisconnect() + let problem = await MainActor.run { + let nextProblem = GatewayConnectionProblemMapper.map( + error: error, + preserving: self.lastGatewayProblem) + if let nextProblem { + self.applyGatewayConnectionProblem(nextProblem) + } else { + self.lastGatewayProblem = nil + self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.gatewayConnected = false + self.showLocalCanvasOnDisconnect() + } + return nextProblem } GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)") - // If auth is missing/rejected, pause reconnect churn until the user intervenes. - // Reconnect loops only spam the same failing handshake and make onboarding noisy. - let lower = error.localizedDescription.lowercased() - if lower.contains("unauthorized") || lower.contains("gateway token missing") { - await MainActor.run { - self.gatewayAutoReconnectEnabled = false - } - } - - // 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. - 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.. [String] { var lines: [String] = [ - "gateway: \(appModel.gatewayStatusText)", + "gateway: \(appModel.gatewayDisplayStatusText)", "discovery: \(gatewayController.discoveryStatusText)", ] lines.append("server: \(appModel.gatewayServerName ?? "—")") diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index ce5362264ca..e72fe23332b 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -69,6 +69,7 @@ struct OnboardingWizardView: View { @State private var showQRScanner: Bool = false @State private var scannerError: String? @State private var selectedPhoto: PhotosPickerItem? + @State private var showGatewayProblemDetails: Bool = false @State private var lastPairingAutoResumeAttemptAt: Date? private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() @@ -86,6 +87,10 @@ struct OnboardingWizardView: View { self.step == .intro || self.step == .welcome || self.step == .success } + private var currentProblem: GatewayConnectionProblem? { + self.appModel.lastGatewayProblem + } + var body: some View { NavigationStack { Group { @@ -216,6 +221,16 @@ struct OnboardingWizardView: View { } } } + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let currentProblem = self.currentProblem { + GatewayProblemDetailsSheet( + problem: currentProblem, + primaryActionTitle: "Retry", + onPrimaryAction: { + Task { await self.retryLastAttempt() } + }) + } + } .onAppear { self.initializeState() } @@ -250,39 +265,11 @@ struct OnboardingWizardView: View { .onChange(of: self.gatewayPassword) { _, newValue in self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) } + .onChange(of: self.appModel.lastGatewayProblem) { _, newValue in + self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText) + } .onChange(of: self.appModel.gatewayStatusText) { _, newValue in - let next = GatewayConnectionIssue.detect(from: newValue) - // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection - // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns). - 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 if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing { - // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until - // the user retries/scans again or we successfully connect. - } else { - self.issue = next - } - - if let requestId = next.requestId, !requestId.isEmpty { - self.pairingRequestId = requestId - } - - // If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes. - if next.needsAuthToken { - self.appModel.gatewayAutoReconnectEnabled = false - } - - if self.issue.needsAuthToken || self.issue.needsPairing { - self.step = .auth - } - if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.connectMessage = newValue - self.statusLine = newValue - } + self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue) } .onChange(of: self.appModel.gatewayServerName) { _, newValue in guard newValue != nil else { return } @@ -509,7 +496,7 @@ struct OnboardingWizardView: View { Section { LabeledContent("Mode", value: selectedMode.title) LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) - LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText) LabeledContent("Progress", value: self.statusLine) } header: { Text("Status") @@ -612,7 +599,17 @@ struct OnboardingWizardView: View { .autocorrectionDisabled() SecureField("Gateway Password", text: self.$gatewayPassword) - if self.issue.needsAuthToken { + if let problem = self.currentProblem { + GatewayProblemBanner( + problem: problem, + primaryActionTitle: "Retry connection", + onPrimaryAction: { + Task { await self.retryLastAttempt() } + }, + onShowDetails: { + self.showGatewayProblemDetails = true + }) + } else if self.issue.needsAuthToken { Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.") .font(.footnote) .foregroundStyle(.secondary) @@ -635,14 +632,15 @@ struct OnboardingWizardView: View { Text("Pairing Approval") } footer: { let requestLine: String = { - if let id = self.issue.requestId, !id.isEmpty { + if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty { return "Request ID: \(id)" } return "Request ID: check `openclaw devices list`." }() + let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve " Text( "Approve this device on the gateway.\n" - + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" + + "1) `\(commandLine)`\n" + "2) `/pair approve` in your OpenClaw chat\n" + "\(requestLine)\n" + "OpenClaw will also retry automatically when you return to this app.") @@ -824,6 +822,45 @@ struct OnboardingWizardView: View { self.resumeAfterPairingApprovalInBackground() } + private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) { + let next = GatewayConnectionIssue.detect(problem: problem) + let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next + + // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection + // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns). + if self.issue.needsPairing, fallback.needsPairing { + let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId + self.issue = .pairingRequired(requestId: mergedRequestId) + } else if self.issue.needsPairing, !fallback.needsPairing { + // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect. + } else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing { + // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until + // the user retries/scans again or we successfully connect. + } else { + self.issue = fallback + } + + if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty { + self.pairingRequestId = requestId + } + + if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true { + self.step = .auth + } + + if let problem { + self.connectMessage = problem.message + self.statusLine = problem.message + return + } + + let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedStatus.isEmpty { + self.connectMessage = trimmedStatus + self.statusLine = trimmedStatus + } + } + private func detectQRCode(from data: Data) -> String? { guard let ciImage = CIImage(data: data) else { return nil } let detector = CIDetector( diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index d11f2d45001..5f8b0729e35 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -98,6 +98,9 @@ struct RootCanvas: View { }, openSettings: { self.presentedSheet = .settings + }, + retryGatewayConnection: { + Task { await self.gatewayController.connectLastKnown() } }) .preferredColorScheme(.dark) @@ -229,7 +232,7 @@ struct RootCanvas: View { private func updateCanvasDebugStatus() { self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) guard self.canvasDebugStatusEnabled else { return } - let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } @@ -454,6 +457,7 @@ private struct CanvasContent: View { @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @State private var showGatewayActions: Bool = false + @State private var showGatewayProblemDetails: Bool = false var systemColorScheme: ColorScheme var gatewayStatus: StatusPill.GatewayState var voiceWakeEnabled: Bool @@ -462,6 +466,7 @@ private struct CanvasContent: View { var cameraHUDKind: NodeAppModel.CameraHUDKind? var openChat: () -> Void var openSettings: () -> Void + var retryGatewayConnection: () -> Void private var brightenButtons: Bool { self.systemColorScheme == .light } private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled } @@ -488,6 +493,8 @@ private struct CanvasContent: View { onStatusTap: { if self.gatewayStatus == .connected { self.showGatewayActions = true + } else if self.appModel.lastGatewayProblem != nil { + self.showGatewayProblemDetails = true } else { self.openSettings() } @@ -504,13 +511,35 @@ private struct CanvasContent: View { self.openSettings() }) } + .overlay(alignment: .top) { + if let gatewayProblem = self.appModel.lastGatewayProblem, + self.gatewayStatus != .connected + { + GatewayProblemBanner( + problem: gatewayProblem, + primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings", + onPrimaryAction: { + if gatewayProblem.retryable { + self.retryGatewayConnection() + } else { + self.openSettings() + } + }, + onShowDetails: { + self.showGatewayProblemDetails = true + }) + .padding(.horizontal, 12) + .safeAreaPadding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { VoiceWakeToast( command: voiceWakeToastText, brighten: self.brightenButtons) .padding(.leading, 10) - .safeAreaPadding(.top, 58) + .safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132) .transition(.move(edge: .top).combined(with: .opacity)) } } @@ -518,6 +547,16 @@ private struct CanvasContent: View { isPresented: self.$showGatewayActions, onDisconnect: { self.appModel.disconnectGateway() }, onOpenSettings: { self.openSettings() }) + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemDetailsSheet( + problem: gatewayProblem, + primaryActionTitle: "Open Settings", + onPrimaryAction: { + self.openSettings() + }) + } + } .onAppear { // Keep the runtime talk state aligned with persisted toggle state on cold launch. if self.talkEnabled != self.appModel.talkMode.isEnabled { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index fb517672588..e79032bd84e 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -9,6 +9,7 @@ struct RootTabs: View { @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? @State private var showGatewayActions: Bool = false + @State private var showGatewayProblemDetails: Bool = false var body: some View { TabView(selection: self.$selectedTab) { @@ -32,6 +33,8 @@ struct RootTabs: View { onTap: { if self.gatewayStatus == .connected { self.showGatewayActions = true + } else if self.appModel.lastGatewayProblem != nil { + self.showGatewayProblemDetails = true } else { self.selectedTab = 2 } @@ -39,11 +42,29 @@ struct RootTabs: View { .padding(.leading, 10) .safeAreaPadding(.top, 10) } + .overlay(alignment: .top) { + if let gatewayProblem = self.appModel.lastGatewayProblem, + self.gatewayStatus != .connected + { + GatewayProblemBanner( + problem: gatewayProblem, + primaryActionTitle: "Open Settings", + onPrimaryAction: { + self.selectedTab = 2 + }, + onShowDetails: { + self.showGatewayProblemDetails = true + }) + .padding(.horizontal, 12) + .safeAreaPadding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { VoiceWakeToast(command: voiceWakeToastText) .padding(.leading, 10) - .safeAreaPadding(.top, 58) + .safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132) .transition(.move(edge: .top).combined(with: .opacity)) } } @@ -74,6 +95,16 @@ struct RootTabs: View { isPresented: self.$showGatewayActions, onDisconnect: { self.appModel.disconnectGateway() }, onOpenSettings: { self.selectedTab = 2 }) + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemDetailsSheet( + problem: gatewayProblem, + primaryActionTitle: "Open Settings", + onPrimaryAction: { + self.selectedTab = 2 + }) + } + } } private var gatewayStatus: StatusPill.GatewayState { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 8d180e6b7f4..2d2d936f315 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -53,6 +53,7 @@ struct SettingsTab: View { @State private var selectedAgentPickerId: String = "" @State private var showResetOnboardingAlert: Bool = false + @State private var showGatewayProblemDetails: Bool = false @State private var activeFeatureHelp: FeatureHelp? @State private var suppressCredentialPersist: Bool = false @@ -63,6 +64,20 @@ struct SettingsTab: View { Form { Section { DisclosureGroup(isExpanded: self.$gatewayExpanded) { + if let gatewayProblem = self.appModel.lastGatewayProblem, + !self.isGatewayConnected + { + GatewayProblemBanner( + problem: gatewayProblem, + primaryActionTitle: "Retry connection", + onPrimaryAction: { + Task { await self.retryGatewayConnectionFromProblem() } + }, + onShowDetails: { + self.showGatewayProblemDetails = true + }) + } + if !self.isGatewayConnected { Text( "1. Open a chat with your OpenClaw agent and send /pair\n" @@ -123,7 +138,7 @@ struct SettingsTab: View { if self.appModel.gatewayServerName == nil { LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) } - LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText) Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect) if let serverName = self.appModel.gatewayServerName { @@ -402,6 +417,16 @@ struct SettingsTab: View { .accessibilityLabel("Close") } } + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemDetailsSheet( + problem: gatewayProblem, + primaryActionTitle: "Retry", + onPrimaryAction: { + Task { await self.retryGatewayConnectionFromProblem() } + }) + } + } .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { Button("Reset", role: .destructive) { self.resetOnboarding() @@ -593,6 +618,9 @@ struct SettingsTab: View { if let server = self.appModel.gatewayServerName, self.isGatewayConnected { return server } + if let problem = self.appModel.lastGatewayProblem { + return problem.statusText + } let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Not connected" : trimmed } @@ -642,7 +670,7 @@ struct SettingsTab: View { private func gatewayDebugText() -> String { var lines: [String] = [ - "gateway: \(self.appModel.gatewayStatusText)", + "gateway: \(self.appModel.gatewayDisplayStatusText)", "discovery: \(self.gatewayController.discoveryStatusText)", ] lines.append("server: \(self.appModel.gatewayServerName ?? "—")") @@ -889,6 +917,9 @@ struct SettingsTab: View { } private var setupStatusLine: String? { + if let problem = self.appModel.lastGatewayProblem { + return problem.message + } let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly } @@ -987,6 +1018,14 @@ struct SettingsTab: View { SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) } + private func retryGatewayConnectionFromProblem() async { + if self.manualGatewayEnabled || self.connectingGatewayID == "manual" { + await self.connectManual() + return + } + await self.connectLastKnown() + } + private func resetOnboarding() { // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. self.appModel.disconnectGateway() diff --git a/apps/ios/Sources/Status/GatewayStatusBuilder.swift b/apps/ios/Sources/Status/GatewayStatusBuilder.swift index dd15f586521..a5fd0bdda0f 100644 --- a/apps/ios/Sources/Status/GatewayStatusBuilder.swift +++ b/apps/ios/Sources/Status/GatewayStatusBuilder.swift @@ -1,11 +1,24 @@ import Foundation +import OpenClawKit enum GatewayStatusBuilder { @MainActor static func build(appModel: NodeAppModel) -> StatusPill.GatewayState { - if appModel.gatewayServerName != nil { return .connected } + self.build( + gatewayServerName: appModel.gatewayServerName, + lastGatewayProblem: appModel.lastGatewayProblem, + gatewayStatusText: appModel.gatewayStatusText) + } - let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + static func build( + gatewayServerName: String?, + lastGatewayProblem: GatewayConnectionProblem?, + gatewayStatusText: String) -> StatusPill.GatewayState + { + if gatewayServerName != nil { return .connected } + if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error } + + let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || text.localizedCaseInsensitiveContains("reconnecting") { diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift index 381b3d2b9e8..21a2edd2973 100644 --- a/apps/ios/Sources/Status/StatusActivityBuilder.swift +++ b/apps/ios/Sources/Status/StatusActivityBuilder.swift @@ -16,6 +16,31 @@ enum StatusActivityBuilder { tint: .orange) } + if let gatewayProblem = appModel.lastGatewayProblem { + switch gatewayProblem.kind { + case .pairingRequired, + .pairingRoleUpgradeRequired, + .pairingScopeUpgradeRequired, + .pairingMetadataUpgradeRequired: + return StatusPill.Activity( + title: "Approval pending", + systemImage: "person.crop.circle.badge.clock", + tint: .orange) + case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled: + return StatusPill.Activity( + title: "Check network", + systemImage: "wifi.exclamationmark", + tint: .orange) + default: + if gatewayProblem.pauseReconnect { + return StatusPill.Activity( + title: "Action required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + } + } + let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayLower = gatewayStatus.lowercased() if gatewayLower.contains("repair") { diff --git a/apps/ios/Tests/GatewayStatusBuilderTests.swift b/apps/ios/Tests/GatewayStatusBuilderTests.swift new file mode 100644 index 00000000000..8c0984e8938 --- /dev/null +++ b/apps/ios/Tests/GatewayStatusBuilderTests.swift @@ -0,0 +1,36 @@ +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite struct GatewayStatusBuilderTests { + @Test func pausedProblemKeepsErrorStatus() { + let state = GatewayStatusBuilder.build( + gatewayServerName: nil, + lastGatewayProblem: GatewayConnectionProblem( + kind: .pairingRequired, + owner: .gateway, + title: "Pairing required", + message: "Approve this device before reconnecting.", + requestId: "req-123", + retryable: false, + pauseReconnect: true), + gatewayStatusText: "Reconnecting…") + + #expect(state == .error) + } + + @Test func transientProblemAllowsConnectingStatus() { + let state = GatewayStatusBuilder.build( + gatewayServerName: nil, + lastGatewayProblem: GatewayConnectionProblem( + kind: .timeout, + owner: .network, + title: "Connection timed out", + message: "The gateway did not respond before the connection timed out.", + retryable: true, + pauseReconnect: false), + gatewayStatusText: "Reconnecting…") + + #expect(state == .connecting) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 42c44e2e63e..3f09e44e1d3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -624,11 +624,31 @@ public actor GatewayChannelActor { let detailCode = details?["code"]?.value as? String let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false let recommendedNextStep = details?["recommendedNextStep"]?.value as? String + let requestId = details?["requestId"]?.value as? String + let reason = details?["reason"]?.value as? String + let owner = details?["owner"]?.value as? String + let title = details?["title"]?.value as? String + let userMessage = details?["userMessage"]?.value as? String + let actionLabel = details?["actionLabel"]?.value as? String + let actionCommand = details?["actionCommand"]?.value as? String + let docsURLString = details?["docsUrl"]?.value as? String + let retryableOverride = details?["retryable"]?.value as? Bool + let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool throw GatewayConnectAuthError( message: msg, detailCodeRaw: detailCode, canRetryWithDeviceToken: canRetryWithDeviceToken, - recommendedNextStepRaw: recommendedNextStep) + recommendedNextStepRaw: recommendedNextStep, + requestId: requestId, + detailsReason: reason, + ownerRaw: owner, + titleOverride: title, + userMessageOverride: userMessage, + actionLabel: actionLabel, + actionCommand: actionCommand, + docsURLString: docsURLString, + retryableOverride: retryableOverride, + pauseReconnectOverride: pauseReconnectOverride) } guard let payload = res.payload else { throw NSError( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift new file mode 100644 index 00000000000..fb015613d1c --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift @@ -0,0 +1,761 @@ +import Foundation + +public struct GatewayConnectionProblem: Equatable, Sendable { + public enum Kind: String, Equatable, Sendable { + case gatewayAuthTokenMissing + case gatewayAuthTokenMismatch + case gatewayAuthTokenNotConfigured + case gatewayAuthPasswordMissing + case gatewayAuthPasswordMismatch + case gatewayAuthPasswordNotConfigured + case bootstrapTokenInvalid + case deviceTokenMismatch + case pairingRequired + case pairingRoleUpgradeRequired + case pairingScopeUpgradeRequired + case pairingMetadataUpgradeRequired + case deviceIdentityRequired + case deviceSignatureExpired + case deviceNonceRequired + case deviceNonceMismatch + case deviceSignatureInvalid + case devicePublicKeyInvalid + case deviceIdMismatch + case tailscaleIdentityMissing + case tailscaleProxyMissing + case tailscaleWhoisFailed + case tailscaleIdentityMismatch + case authRateLimited + case timeout + case connectionRefused + case reachabilityFailed + case websocketCancelled + case unknown + } + + public enum Owner: String, Equatable, Sendable { + case gateway + case iphone + case both + case network + case unknown + } + + public let kind: Kind + public let owner: Owner + public let title: String + public let message: String + public let actionLabel: String? + public let actionCommand: String? + public let docsURL: URL? + public let requestId: String? + public let retryable: Bool + public let pauseReconnect: Bool + public let technicalDetails: String? + + public init( + kind: Kind, + owner: Owner, + title: String, + message: String, + actionLabel: String? = nil, + actionCommand: String? = nil, + docsURL: URL? = nil, + requestId: String? = nil, + retryable: Bool, + pauseReconnect: Bool, + technicalDetails: String? = nil) + { + self.kind = kind + self.owner = owner + self.title = title + self.message = message + self.actionLabel = Self.trimmedOrNil(actionLabel) + self.actionCommand = Self.trimmedOrNil(actionCommand) + self.docsURL = docsURL + self.requestId = Self.trimmedOrNil(requestId) + self.retryable = retryable + self.pauseReconnect = pauseReconnect + self.technicalDetails = Self.trimmedOrNil(technicalDetails) + } + + public var needsPairingApproval: Bool { + switch self.kind { + case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired: + return true + default: + return false + } + } + + public var needsCredentialUpdate: Bool { + switch self.kind { + case .gatewayAuthTokenMissing, + .gatewayAuthTokenMismatch, + .gatewayAuthTokenNotConfigured, + .gatewayAuthPasswordMissing, + .gatewayAuthPasswordMismatch, + .gatewayAuthPasswordNotConfigured, + .bootstrapTokenInvalid, + .deviceTokenMismatch: + return true + default: + return false + } + } + + public var statusText: String { + switch self.kind { + case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired: + if let requestId { + return "\(self.title) (request ID: \(requestId))" + } + return self.title + default: + return self.title + } + } + + private static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} + +public enum GatewayConnectionProblemMapper { + public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? { + guard let nextProblem = self.rawMap(error) else { + return nil + } + guard let previousProblem else { + return nextProblem + } + if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) { + return previousProblem + } + return nextProblem + } + + public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool { + if nextProblem.kind == .websocketCancelled { + return previousProblem.pauseReconnect || previousProblem.requestId != nil + } + return false + } + + public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool { + let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + if normalized.contains("cancelled") || normalized.contains("canceled") { + return previousProblem.pauseReconnect || previousProblem.requestId != nil + } + return false + } + + private static func rawMap(_ error: Error) -> GatewayConnectionProblem? { + if let authError = error as? GatewayConnectAuthError { + return self.map(authError) + } + if let responseError = error as? GatewayResponseError { + return self.map(responseError) + } + return self.mapTransportError(error) + } + + private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem { + let pairingCommand = self.approvalCommand(requestId: authError.requestId) + + switch authError.detail { + case .authTokenMissing: + return self.problem( + kind: .gatewayAuthTokenMissing, + owner: .both, + title: authError.titleOverride ?? "Gateway token required", + message: authError.userMessageOverride + ?? "This gateway requires an auth token, but this iPhone did not send one.", + actionLabel: authError.actionLabel ?? "Open Settings", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authTokenMismatch: + return self.problem( + kind: .gatewayAuthTokenMismatch, + owner: .both, + title: authError.titleOverride ?? "Gateway token is out of date", + message: authError.userMessageOverride + ?? "The token on this iPhone does not match the gateway token.", + actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"), + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken, + pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken, + authError: authError) + case .authTokenNotConfigured: + return self.problem( + kind: .gatewayAuthTokenNotConfigured, + owner: .gateway, + title: authError.titleOverride ?? "Gateway token is not configured", + message: authError.userMessageOverride + ?? "This gateway is set to token auth, but no gateway token is configured on the gateway.", + actionLabel: authError.actionLabel ?? "Fix on gateway", + actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token ", + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authPasswordMissing: + return self.problem( + kind: .gatewayAuthPasswordMissing, + owner: .both, + title: authError.titleOverride ?? "Gateway password required", + message: authError.userMessageOverride + ?? "This gateway requires a password, but this iPhone did not send one.", + actionLabel: authError.actionLabel ?? "Open Settings", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authPasswordMismatch: + return self.problem( + kind: .gatewayAuthPasswordMismatch, + owner: .both, + title: authError.titleOverride ?? "Gateway password is out of date", + message: authError.userMessageOverride + ?? "The saved password on this iPhone does not match the gateway password.", + actionLabel: authError.actionLabel ?? "Update password", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authPasswordNotConfigured: + return self.problem( + kind: .gatewayAuthPasswordNotConfigured, + owner: .gateway, + title: authError.titleOverride ?? "Gateway password is not configured", + message: authError.userMessageOverride + ?? "This gateway is set to password auth, but no gateway password is configured on the gateway.", + actionLabel: authError.actionLabel ?? "Fix on gateway", + actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password ", + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authBootstrapTokenInvalid: + return self.problem( + kind: .bootstrapTokenInvalid, + owner: .iphone, + title: authError.titleOverride ?? "Setup code expired", + message: authError.userMessageOverride + ?? "The setup QR or bootstrap token is no longer valid.", + actionLabel: authError.actionLabel ?? "Scan QR again", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authDeviceTokenMismatch: + return self.problem( + kind: .deviceTokenMismatch, + owner: .both, + title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid", + message: authError.userMessageOverride + ?? "The gateway rejected the stored device token for this role.", + actionLabel: authError.actionLabel ?? "Repair pairing", + actionCommand: authError.actionCommand ?? pairingCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .pairingRequired: + return self.pairingProblem(for: authError) + case .controlUiDeviceIdentityRequired, .deviceIdentityRequired: + return self.problem( + kind: .deviceIdentityRequired, + owner: .iphone, + title: authError.titleOverride ?? "Secure device identity is required", + message: authError.userMessageOverride + ?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.", + actionLabel: authError.actionLabel ?? "Retry from the app", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .deviceAuthSignatureExpired: + return self.problem( + kind: .deviceSignatureExpired, + owner: .iphone, + title: authError.titleOverride ?? "Secure handshake expired", + message: authError.userMessageOverride ?? "The device signature is too old to use.", + actionLabel: authError.actionLabel ?? "Check iPhone time", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), + requestId: authError.requestId, + retryable: true, + pauseReconnect: true, + authError: authError) + case .deviceAuthNonceRequired: + return self.problem( + kind: .deviceNonceRequired, + owner: .iphone, + title: authError.titleOverride ?? "Secure handshake is incomplete", + message: authError.userMessageOverride + ?? "The gateway expected a one-time challenge response, but the nonce was missing.", + actionLabel: authError.actionLabel ?? "Retry", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), + requestId: authError.requestId, + retryable: true, + pauseReconnect: true, + authError: authError) + case .deviceAuthNonceMismatch: + return self.problem( + kind: .deviceNonceMismatch, + owner: .iphone, + title: authError.titleOverride ?? "Secure handshake did not match", + message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.", + actionLabel: authError.actionLabel ?? "Retry", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), + requestId: authError.requestId, + retryable: true, + pauseReconnect: true, + authError: authError) + case .deviceAuthSignatureInvalid, .deviceAuthInvalid: + return self.problem( + kind: .deviceSignatureInvalid, + owner: .iphone, + title: authError.titleOverride ?? "This device identity could not be verified", + message: authError.userMessageOverride + ?? "The gateway could not verify the identity this iPhone presented.", + actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .deviceAuthPublicKeyInvalid: + return self.problem( + kind: .devicePublicKeyInvalid, + owner: .iphone, + title: authError.titleOverride ?? "This device identity could not be verified", + message: authError.userMessageOverride + ?? "The gateway could not verify the public key this iPhone presented.", + actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .deviceAuthDeviceIdMismatch: + return self.problem( + kind: .deviceIdMismatch, + owner: .iphone, + title: authError.titleOverride ?? "This device identity could not be verified", + message: authError.userMessageOverride + ?? "The gateway rejected the device identity because the device ID did not match.", + actionLabel: authError.actionLabel ?? "Re-pair this iPhone", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authTailscaleIdentityMissing: + return self.problem( + kind: .tailscaleIdentityMissing, + owner: .network, + title: authError.titleOverride ?? "Tailscale identity check failed", + message: authError.userMessageOverride + ?? "This connection expected Tailscale identity headers, but they were not available.", + actionLabel: authError.actionLabel ?? "Turn on Tailscale", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authTailscaleProxyMissing: + return self.problem( + kind: .tailscaleProxyMissing, + owner: .network, + title: authError.titleOverride ?? "Tailscale identity check failed", + message: authError.userMessageOverride + ?? "The gateway expected a Tailscale auth proxy, but it was not configured.", + actionLabel: authError.actionLabel ?? "Review Tailscale setup", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authTailscaleWhoisFailed: + return self.problem( + kind: .tailscaleWhoisFailed, + owner: .network, + title: authError.titleOverride ?? "Tailscale identity check failed", + message: authError.userMessageOverride + ?? "The gateway could not verify this Tailscale client identity.", + actionLabel: authError.actionLabel ?? "Review Tailscale setup", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authTailscaleIdentityMismatch: + return self.problem( + kind: .tailscaleIdentityMismatch, + owner: .network, + title: authError.titleOverride ?? "Tailscale identity check failed", + message: authError.userMessageOverride + ?? "The forwarded Tailscale identity did not match the verified identity.", + actionLabel: authError.actionLabel ?? "Review Tailscale setup", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authRateLimited: + return self.problem( + kind: .authRateLimited, + owner: .gateway, + title: authError.titleOverride ?? "Too many failed attempts", + message: authError.userMessageOverride + ?? "The gateway is temporarily refusing new auth attempts after repeated failures.", + actionLabel: authError.actionLabel ?? "Wait and retry", + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"), + requestId: authError.requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case .authRequired, .authUnauthorized, .none: + return self.problem( + kind: .unknown, + owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown, + title: authError.titleOverride ?? "Gateway rejected the connection", + message: authError.userMessageOverride ?? authError.message, + actionLabel: authError.actionLabel, + actionCommand: authError.actionCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: nil), + requestId: authError.requestId, + retryable: authError.retryableOverride ?? false, + pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable, + authError: authError) + } + } + + private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? { + let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" { + let authError = GatewayConnectAuthError( + message: responseError.message, + detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue, + canRetryWithDeviceToken: false, + recommendedNextStepRaw: nil, + requestId: self.stringValue(responseError.details["requestId"]?.value), + detailsReason: responseError.detailsReason, + ownerRaw: nil, + titleOverride: nil, + userMessageOverride: nil, + actionLabel: nil, + actionCommand: nil, + docsURLString: nil, + retryableOverride: nil, + pauseReconnectOverride: nil) + return self.map(authError) + } + return nil + } + + private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? { + let nsError = error as NSError + let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription + let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lower.isEmpty { + return nil + } + + let urlErrorCode = URLError.Code(rawValue: nsError.code) + if nsError.domain == URLError.errorDomain { + switch urlErrorCode { + case .timedOut: + return GatewayConnectionProblem( + kind: .timeout, + owner: .network, + title: "Connection timed out", + message: "The gateway did not respond before the connection timed out.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + case .cannotConnectToHost: + return GatewayConnectionProblem( + kind: .connectionRefused, + owner: .network, + title: "Gateway refused the connection", + message: "The gateway host was reachable, but it refused the connection.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed: + return GatewayConnectionProblem( + kind: .reachabilityFailed, + owner: .network, + title: "Gateway is not reachable", + message: "OpenClaw could not reach the gateway over the current network.", + actionLabel: "Check network", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + case .cancelled: + return GatewayConnectionProblem( + kind: .websocketCancelled, + owner: .network, + title: "Connection interrupted", + message: "The connection to the gateway was interrupted before setup completed.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + default: + break + } + } + + if lower.contains("timed out") { + return GatewayConnectionProblem( + kind: .timeout, + owner: .network, + title: "Connection timed out", + message: "The gateway did not respond before the connection timed out.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + } + if lower.contains("connection refused") || lower.contains("refused") { + return GatewayConnectionProblem( + kind: .connectionRefused, + owner: .network, + title: "Gateway refused the connection", + message: "The gateway host was reachable, but it refused the connection.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + } + if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") { + return GatewayConnectionProblem( + kind: .reachabilityFailed, + owner: .network, + title: "Gateway is not reachable", + message: "OpenClaw could not reach the gateway over the current network.", + actionLabel: "Check network", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + } + if lower.contains("cancelled") || lower.contains("canceled") { + return GatewayConnectionProblem( + kind: .websocketCancelled, + owner: .network, + title: "Connection interrupted", + message: "The connection to the gateway was interrupted before setup completed.", + actionLabel: "Retry", + actionCommand: nil, + docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"), + retryable: true, + pauseReconnect: false, + technicalDetails: rawMessage) + } + return nil + } + + private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem { + let requestId = authError.requestId + let pairingCommand = self.approvalCommand(requestId: requestId) + + switch authError.detailsReason { + case "role-upgrade": + return self.problem( + kind: .pairingRoleUpgradeRequired, + owner: .gateway, + title: authError.titleOverride ?? "Additional approval required", + message: authError.userMessageOverride + ?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.", + actionLabel: authError.actionLabel ?? "Approve on gateway", + actionCommand: authError.actionCommand ?? pairingCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case "scope-upgrade": + return self.problem( + kind: .pairingScopeUpgradeRequired, + owner: .gateway, + title: authError.titleOverride ?? "Additional permissions required", + message: authError.userMessageOverride + ?? "This iPhone is already paired, but it is requesting new permissions that require approval.", + actionLabel: authError.actionLabel ?? "Approve on gateway", + actionCommand: authError.actionCommand ?? pairingCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + case "metadata-upgrade": + return self.problem( + kind: .pairingMetadataUpgradeRequired, + owner: .gateway, + title: authError.titleOverride ?? "Device approval needs refresh", + message: authError.userMessageOverride + ?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.", + actionLabel: authError.actionLabel ?? "Approve on gateway", + actionCommand: authError.actionCommand ?? pairingCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + default: + return self.problem( + kind: .pairingRequired, + owner: .gateway, + title: authError.titleOverride ?? "This iPhone is not approved yet", + message: authError.userMessageOverride + ?? "The gateway received the connection request, but this device must be approved first.", + actionLabel: authError.actionLabel ?? "Approve on gateway", + actionCommand: authError.actionCommand ?? pairingCommand, + docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"), + requestId: requestId, + retryable: false, + pauseReconnect: true, + authError: authError) + } + } + + private static func problem( + kind: GatewayConnectionProblem.Kind, + owner: GatewayConnectionProblem.Owner, + title: String, + message: String, + actionLabel: String?, + actionCommand: String?, + docsURL: URL?, + requestId: String?, + retryable: Bool, + pauseReconnect: Bool, + authError: GatewayConnectAuthError) + -> GatewayConnectionProblem + { + GatewayConnectionProblem( + kind: kind, + owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner, + title: title, + message: message, + actionLabel: actionLabel, + actionCommand: actionCommand, + docsURL: docsURL, + requestId: requestId, + retryable: authError.retryableOverride ?? retryable, + pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect, + technicalDetails: self.technicalDetails(for: authError)) + } + + private static func approvalCommand(requestId: String?) -> String { + if let requestId = self.nonEmpty(requestId) { + return "openclaw devices approve \(requestId)" + } + return "openclaw devices list" + } + + private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? { + var parts: [String] = [] + if let detail = self.nonEmpty(authError.detailCodeRaw) { + parts.append(detail) + } + if let reason = self.nonEmpty(authError.detailsReason) { + parts.append("reason=\(reason)") + } + if let requestId = self.nonEmpty(authError.requestId) { + parts.append("requestId=\(requestId)") + } + if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) { + parts.append("next=\(nextStep)") + } + if authError.canRetryWithDeviceToken { + parts.append("deviceTokenRetry=true") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private static func docsURL(_ preferred: String?, fallback: String?) -> URL? { + if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) { + return url + } + if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) { + return url + } + return nil + } + + private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "gateway": + return .gateway + case "iphone", "ios", "device": + return .iphone + case "both": + return .both + case "network": + return .network + case "unknown", "": + return .unknown + default: + return nil + } + } + + private static func stringValue(_ value: Any?) -> String? { + self.nonEmpty(value as? String) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift index a20bf3e66e3..7009f85a70c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift @@ -43,12 +43,32 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable { public let detailCodeRaw: String? public let recommendedNextStepRaw: String? public let canRetryWithDeviceToken: Bool + public let requestId: String? + public let detailsReason: String? + public let ownerRaw: String? + public let titleOverride: String? + public let userMessageOverride: String? + public let actionLabel: String? + public let actionCommand: String? + public let docsURLString: String? + public let retryableOverride: Bool? + public let pauseReconnectOverride: Bool? public init( message: String, detailCodeRaw: String?, canRetryWithDeviceToken: Bool, - recommendedNextStepRaw: String? = nil) + recommendedNextStepRaw: String? = nil, + requestId: String? = nil, + detailsReason: String? = nil, + ownerRaw: String? = nil, + titleOverride: String? = nil, + userMessageOverride: String? = nil, + actionLabel: String? = nil, + actionCommand: String? = nil, + docsURLString: String? = nil, + retryableOverride: Bool? = nil, + pauseReconnectOverride: Bool? = nil) { let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -59,19 +79,54 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable { self.canRetryWithDeviceToken = canRetryWithDeviceToken self.recommendedNextStepRaw = trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil + self.requestId = Self.trimmedOrNil(requestId) + self.detailsReason = Self.trimmedOrNil(detailsReason) + self.ownerRaw = Self.trimmedOrNil(ownerRaw) + self.titleOverride = Self.trimmedOrNil(titleOverride) + self.userMessageOverride = Self.trimmedOrNil(userMessageOverride) + self.actionLabel = Self.trimmedOrNil(actionLabel) + self.actionCommand = Self.trimmedOrNil(actionCommand) + self.docsURLString = Self.trimmedOrNil(docsURLString) + self.retryableOverride = retryableOverride + self.pauseReconnectOverride = pauseReconnectOverride } public init( message: String, detailCode: String?, canRetryWithDeviceToken: Bool, - recommendedNextStep: String? = nil) + recommendedNextStep: String? = nil, + requestId: String? = nil, + detailsReason: String? = nil, + ownerRaw: String? = nil, + titleOverride: String? = nil, + userMessageOverride: String? = nil, + actionLabel: String? = nil, + actionCommand: String? = nil, + docsURLString: String? = nil, + retryableOverride: Bool? = nil, + pauseReconnectOverride: Bool? = nil) { self.init( message: message, detailCodeRaw: detailCode, canRetryWithDeviceToken: canRetryWithDeviceToken, - recommendedNextStepRaw: recommendedNextStep) + recommendedNextStepRaw: recommendedNextStep, + requestId: requestId, + detailsReason: detailsReason, + ownerRaw: ownerRaw, + titleOverride: titleOverride, + userMessageOverride: userMessageOverride, + actionLabel: actionLabel, + actionCommand: actionCommand, + docsURLString: docsURLString, + retryableOverride: retryableOverride, + pauseReconnectOverride: pauseReconnectOverride) + } + + private static func trimmedOrNil(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed } public var detailCode: String? { self.detailCodeRaw } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift index 92d3e1292de..88723c0cedc 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -1,3 +1,4 @@ +import Foundation import OpenClawKit import Testing @@ -11,4 +12,81 @@ import Testing #expect(error.isNonRecoverable) #expect(error.detail == .authBootstrapTokenInvalid) } + + @Test func connectAuthErrorPreservesStructuredMetadata() { + let error = GatewayConnectAuthError( + message: "pairing required", + detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue, + canRetryWithDeviceToken: false, + recommendedNextStep: "review_auth_configuration", + requestId: "req-123", + detailsReason: "scope-upgrade", + ownerRaw: "gateway", + titleOverride: "Additional permissions required", + userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.", + actionLabel: "Approve on gateway", + actionCommand: "openclaw devices approve req-123", + docsURLString: "https://docs.openclaw.ai/gateway/pairing", + retryableOverride: false, + pauseReconnectOverride: true) + + #expect(error.requestId == "req-123") + #expect(error.detailsReason == "scope-upgrade") + #expect(error.ownerRaw == "gateway") + #expect(error.titleOverride == "Additional permissions required") + #expect(error.actionCommand == "openclaw devices approve req-123") + #expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing") + #expect(error.pauseReconnectOverride == true) + } + + @Test func pairingProblemUsesStructuredRequestMetadata() { + let error = GatewayConnectAuthError( + message: "pairing required", + detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue, + canRetryWithDeviceToken: false, + requestId: "req-123", + detailsReason: "scope-upgrade") + + let problem = GatewayConnectionProblemMapper.map(error: error) + + #expect(problem?.kind == .pairingScopeUpgradeRequired) + #expect(problem?.requestId == "req-123") + #expect(problem?.pauseReconnect == true) + #expect(problem?.actionCommand == "openclaw devices approve req-123") + } + + @Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() { + let pairing = GatewayConnectAuthError( + message: "pairing required", + detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue, + canRetryWithDeviceToken: false, + requestId: "req-123") + let previousProblem = GatewayConnectionProblemMapper.map(error: pairing) + let cancelled = NSError( + domain: URLError.errorDomain, + code: URLError.cancelled.rawValue, + userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"]) + + let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem) + + #expect(preserved?.kind == .pairingRequired) + #expect(preserved?.requestId == "req-123") + } + + @Test func unmappedTransportErrorClearsStaleStructuredProblem() { + let pairing = GatewayConnectAuthError( + message: "pairing required", + detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue, + canRetryWithDeviceToken: false, + requestId: "req-123") + let previousProblem = GatewayConnectionProblemMapper.map(error: pairing) + let unknownTransport = NSError( + domain: NSURLErrorDomain, + code: -1202, + userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"]) + + let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem) + + #expect(mapped == nil) + } }