Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift
Nimrod Gutman 6380c872bc 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)
2026-04-07 21:53:22 +03:00

762 lines
36 KiB
Swift

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
}
}