mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 17:21:13 +00:00
feat(ios): improve gateway connection error ux (#62650)
* feat(ios): improve gateway connection error ux * fix(ios): address gateway problem review feedback * feat(ios): improve gateway connection error ux (#62650) (thanks @ngutman)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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? {
|
||||
|
||||
@@ -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..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw.isEmpty ? nil : raw
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). "
|
||||
+ "Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
if problem?.needsPairingApproval == true {
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
// we don't generate multiple pending requests while waiting for approval.
|
||||
pausedForPairingApproval = true
|
||||
@@ -2311,6 +2331,10 @@ private extension NodeAppModel {
|
||||
break
|
||||
}
|
||||
|
||||
if problem?.pauseReconnect == true {
|
||||
continue
|
||||
}
|
||||
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
@@ -2322,6 +2346,7 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
|
||||
@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayStatusText)",
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
|
||||
@@ -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 <requestId>"
|
||||
Text(
|
||||
"Approve this device on the gateway.\n"
|
||||
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ struct RootTabs: View {
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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") {
|
||||
|
||||
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <new-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 <new-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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user