iOS: port onboarding + QR pairing flow stability (#18162)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a87eadea19
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-16 16:22:51 +00:00
committed by GitHub
parent df6d0ee92b
commit 130e59a9c0
15 changed files with 1668 additions and 37 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky.
## 2026.2.15

View File

@@ -0,0 +1,71 @@
import Foundation
enum GatewayConnectionIssue: Equatable {
case none
case tokenMissing
case unauthorized
case pairingRequired(requestId: String?)
case network
case unknown(String)
var requestId: String? {
if case let .pairingRequired(requestId) = self {
return requestId
}
return nil
}
var needsAuthToken: Bool {
switch self {
case .tokenMissing, .unauthorized:
return true
default:
return false
}
}
var needsPairing: Bool {
if case .pairingRequired = self { return true }
return false
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }
let lower = trimmed.lowercased()
if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
}
if lower.contains("gateway token missing") {
return .tokenMissing
}
if lower.contains("unauthorized") {
return .unauthorized
}
if lower.contains("connection refused") ||
lower.contains("timed out") ||
lower.contains("network is unreachable") ||
lower.contains("cannot find host") ||
lower.contains("could not connect")
{
return .network
}
if lower.hasPrefix("gateway error:") {
return .unknown(trimmed)
}
return .none
}
private static func extractRequestId(from statusText: String) -> String? {
let marker = "requestId:"
guard let range = statusText.range(of: marker) else { return nil }
let suffix = statusText[range.upperBound...]
let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
let end = trimmed.firstIndex(where: { ch in
ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
}) ?? trimmed.endIndex
let id = String(trimmed[..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return id.isEmpty ? nil : id
}
}

View File

@@ -6,10 +6,10 @@ struct GatewayTrustPromptAlert: ViewModifier {
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
Binding(
get: { self.gatewayController.pendingTrustPrompt },
set: { newValue in
if newValue == nil {
self.gatewayController.clearPendingTrustPrompt()
}
set: { _ in
// Keep pending trust state until explicit user action.
// `alert(item:)` may set the binding to nil during dismissal, which can race with
// the button handler and cause accept to no-op.
})
}
@@ -39,4 +39,3 @@ extension View {
self.modifier(GatewayTrustPromptAlert())
}
}

View File

@@ -57,6 +57,11 @@ final class NodeAppModel {
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var gatewayAutoReconnectEnabled: Bool = true
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
// multiple pending requests and cause the onboarding UI to "flip-flop".
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
var selectedAgentId: String?
@@ -340,6 +345,7 @@ final class NodeAppModel {
}
func setTalkEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
// When talk is enabled from the UI, prioritize talk and pause voice wake.
@@ -351,6 +357,11 @@ final class NodeAppModel {
self.talkVoiceWakeSuspended = false
}
self.talkMode.setEnabled(enabled)
Task { [weak self] in
await self?.pushTalkModeToGateway(
enabled: enabled,
phase: enabled ? "enabled" : "disabled")
}
}
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
@@ -479,16 +490,49 @@ final class NodeAppModel {
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue }
guard let payload = evt.payload else { continue }
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
}
}
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
self.setTalkEnabled(enabled)
}
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
guard await self.isOperatorConnected() else { return }
struct TalkModePayload: Encodable {
var enabled: Bool
var phase: String?
}
let payload = TalkModePayload(enabled: enabled, phase: phase)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
_ = try? await self.operatorGateway.request(
method: "talk.mode",
paramsJSON: json,
timeoutSeconds: 8)
}
private func startGatewayHealthMonitor() {
self.gatewayHealthMonitorDisabled = false
self.gatewayHealthMonitor.start(
@@ -577,6 +621,8 @@ final class NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case .gateway:
break
}
}
@@ -1506,6 +1552,8 @@ extension NodeAppModel {
func disconnectGateway() {
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1535,6 +1583,8 @@ extension NodeAppModel {
private extension NodeAppModel {
func prepareForGatewayConnect(url: URL, stableID: String) {
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -1564,6 +1614,10 @@ private extension NodeAppModel {
guard let self else { return }
var attempt = 0
while !Task.isCancelled {
if self.gatewayPairingPaused {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
if await self.isOperatorConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1639,8 +1693,13 @@ private extension NodeAppModel {
var attempt = 0
var currentOptions = nodeOptions
var didFallbackClientId = false
var pausedForPairingApproval = false
while !Task.isCancelled {
if self.gatewayPairingPaused {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
if await self.isGatewayConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
@@ -1669,12 +1728,13 @@ private extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
}
GatewayDiagnostics.log(
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
if let addr = await self.nodeGateway.currentRemoteAddress() {
await MainActor.run { self.gatewayRemoteAddress = addr }
}
await self.showA2UIOnConnectIfNeeded()
await self.onNodeGatewayConnected()
await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) }
},
onDisconnected: { [weak self] reason in
guard let self else { return }
@@ -1726,11 +1786,52 @@ private extension NodeAppModel {
self.showLocalCanvasOnDisconnect()
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
// If pairing is required, stop reconnect churn. The user must approve the request
// on the gateway before another connect attempt will succeed, and retry loops can
// generate multiple pending requests.
let lower = error.localizedDescription.lowercased()
if lower.contains("not_paired") || lower.contains("pairing required") {
let requestId: String? = {
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
// Keep this resilient since other layers may wrap the text.
let text = error.localizedDescription
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
guard let end = text[start...].firstIndex(of: ")") else { return nil }
let raw = String(text[start..<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, then tap Resume."
} else {
self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
}
}
// 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
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
break
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
if pausedForPairingApproval {
// Leave the status text + request id intact so onboarding can guide the user.
return
}
await MainActor.run {
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
@@ -1757,7 +1858,7 @@ private extension NodeAppModel {
clientId: clientId,
clientMode: "ui",
clientDisplayName: displayName,
includeDeviceIdentity: false)
includeDeviceIdentity: true)
}
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
@@ -1775,6 +1876,17 @@ private extension NodeAppModel {
}
}
extension NodeAppModel {
func reloadTalkConfig() {
Task { [weak self] in
await self?.talkMode.reloadConfig()
}
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {}
}
#if DEBUG
extension NodeAppModel {
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@@ -1808,5 +1920,9 @@ extension NodeAppModel {
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
self.applyTalkModeSync(enabled: enabled, phase: phase)
}
}
#endif

View File

@@ -0,0 +1,52 @@
import Foundation
enum OnboardingConnectionMode: String, CaseIterable {
case homeNetwork = "home_network"
case remoteDomain = "remote_domain"
case developerLocal = "developer_local"
var title: String {
switch self {
case .homeNetwork:
"Home Network"
case .remoteDomain:
"Remote Domain"
case .developerLocal:
"Same Machine (Dev)"
}
}
}
enum OnboardingStateStore {
private static let completedDefaultsKey = "onboarding.completed"
private static let lastModeDefaultsKey = "onboarding.last_mode"
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
@MainActor
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
// should handle reconnecting, and users can always open onboarding manually if needed.
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
return appModel.gatewayServerName == nil
}
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
defaults.set(true, forKey: Self.completedDefaultsKey)
if let mode {
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
}
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: Self.completedDefaultsKey)
}
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !raw.isEmpty else { return nil }
return OnboardingConnectionMode(rawValue: raw)
}
}

View File

@@ -0,0 +1,852 @@
import CoreImage
import OpenClawKit
import PhotosUI
import SwiftUI
import UIKit
private enum OnboardingStep: Int, CaseIterable {
case welcome
case mode
case connect
case auth
case success
var previous: Self? {
Self(rawValue: self.rawValue - 1)
}
var next: Self? {
Self(rawValue: self.rawValue + 1)
}
/// Progress label for the manual setup flow (mode connect auth success).
var manualProgressTitle: String {
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
guard let idx = manualSteps.firstIndex(of: self) else { return "" }
return "Step \(idx + 1) of \(manualSteps.count)"
}
var title: String {
switch self {
case .welcome: "Welcome"
case .mode: "Connection Mode"
case .connect: "Connect"
case .auth: "Authentication"
case .success: "Connected"
}
}
var canGoBack: Bool {
self != .welcome && self != .success
}
}
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
@State private var step: OnboardingStep = .welcome
@State private var selectedMode: OnboardingConnectionMode?
@State private var manualHost: String = ""
@State private var manualPort: Int = 18789
@State private var manualPortText: String = "18789"
@State private var manualTLS: Bool = true
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var connectMessage: String?
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
@State private var connectingGatewayID: String?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@State private var didAutoPresentQR = false
@State private var pairingRequestId: String?
@State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
let allowSkip: Bool
let onClose: () -> Void
private var isFullScreenStep: Bool {
self.step == .welcome || self.step == .success
}
var body: some View {
NavigationStack {
Group {
switch self.step {
case .welcome:
self.welcomeStep
case .success:
self.successStep
default:
Form {
switch self.step {
case .mode:
self.modeStep
case .connect:
self.connectStep
case .auth:
self.authStep
default:
EmptyView()
}
}
.scrollDismissesKeyboard(.interactively)
}
}
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !self.isFullScreenStep {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(self.step.title)
.font(.headline)
Text(self.step.manualProgressTitle)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
ToolbarItem(placement: .topBarLeading) {
if self.step.canGoBack {
Button {
self.navigateBack()
} label: {
Label("Back", systemImage: "chevron.left")
}
} else if self.allowSkip {
Button("Close") {
self.onClose()
}
}
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
}
}
}
.gatewayTrustPromptAlert()
.alert("QR Scanner Unavailable", isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedLink(link)
},
onError: { error in
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo")
}
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
self.handleScannedLink(link)
return
}
if let url = URL(string: message),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handleScannedLink(link)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
}
}
}
.onAppear {
self.initializeState()
}
.onDisappear {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
}
.onChange(of: self.discoveryDomain) { _, _ in
self.scheduleDiscoveryRestart()
}
.onChange(of: self.manualPortText) { _, newValue in
let digits = newValue.filter(\.isNumber)
if digits != newValue {
self.manualPortText = digits
return
}
guard let parsed = Int(digits), parsed > 0 else {
self.manualPort = 0
return
}
self.manualPort = min(parsed, 65535)
}
.onChange(of: self.manualPort) { _, newValue in
let normalized = newValue > 0 ? String(newValue) : ""
if self.manualPortText != normalized {
self.manualPortText = normalized
}
}
.onChange(of: self.gatewayToken) { _, newValue in
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.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
}
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
self.statusLine = "Connected."
if !self.didMarkCompleted, let selectedMode {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.onClose()
}
}
@ViewBuilder
private var welcomeStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 64))
.foregroundStyle(.tint)
.padding(.bottom, 20)
Text("Welcome")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
Text("Connect to your OpenClaw gateway")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Spacer()
VStack(spacing: 12) {
Button {
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
} label: {
Label("Scan QR Code", systemImage: "qrcode")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
self.step = .mode
} label: {
Text("Set Up Manually")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.bottom, 12)
Text(self.statusLine)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
@ViewBuilder
private var modeStep: some View {
Section("Connection Mode") {
OnboardingModeRow(
title: OnboardingConnectionMode.homeNetwork.title,
subtitle: "LAN or Tailscale host",
selected: self.selectedMode == .homeNetwork)
{
self.selectMode(.homeNetwork)
}
OnboardingModeRow(
title: OnboardingConnectionMode.remoteDomain.title,
subtitle: "VPS with domain",
selected: self.selectedMode == .remoteDomain)
{
self.selectMode(.remoteDomain)
}
Toggle(
"Developer mode",
isOn: Binding(
get: { self.developerModeEnabled },
set: { newValue in
self.developerModeEnabled = newValue
if !newValue, self.selectedMode == .developerLocal {
self.selectedMode = nil
}
}))
if self.developerModeEnabled {
OnboardingModeRow(
title: OnboardingConnectionMode.developerLocal.title,
subtitle: "For local iOS app development",
selected: self.selectedMode == .developerLocal)
{
self.selectMode(.developerLocal)
}
}
}
Section {
Button("Continue") {
self.step = .connect
}
.disabled(self.selectedMode == nil)
}
}
@ViewBuilder
private var connectStep: some View {
if let selectedMode {
Section {
LabeledContent("Mode", value: selectedMode.title)
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Progress", value: self.statusLine)
} header: {
Text("Status")
} footer: {
if let connectMessage {
Text(connectMessage)
}
}
switch selectedMode {
case .homeNetwork:
self.homeNetworkConnectSection
case .remoteDomain:
self.remoteDomainConnectSection
case .developerLocal:
self.developerConnectSection
}
} else {
Section {
Text("Choose a mode first.")
Button("Back to Mode Selection") {
self.step = .mode
}
}
}
}
private var homeNetworkConnectSection: some View {
Group {
Section("Discovered Gateways") {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.gateways) { gateway in
let hasHost = self.gatewayHasResolvableHost(gateway)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gateway.name)
if let host = gateway.lanHost ?? gateway.tailnetDns {
Text(host)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
Task { await self.connectDiscoveredGateway(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView()
.progressViewStyle(.circular)
} else if !hasHost {
Text("Resolving…")
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil || !hasHost)
}
}
}
Button("Restart Discovery") {
self.gatewayController.restartDiscovery()
}
.disabled(self.connectingGatewayID != nil)
}
self.manualConnectionFieldsSection(title: "Manual Fallback")
}
}
private var remoteDomainConnectSection: some View {
self.manualConnectionFieldsSection(title: "Domain Settings")
}
private var developerConnectSection: some View {
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
} header: {
Text("Developer Local")
} footer: {
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
}
}
private var authStep: some View {
Group {
Section("Authentication") {
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("Auth token looks valid.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if self.issue.needsPairing {
Section {
Button("Copy: openclaw devices list") {
UIPasteboard.general.string = "openclaw devices list"
}
if let id = self.issue.requestId {
Button("Copy: openclaw devices approve \(id)") {
UIPasteboard.general.string = "openclaw devices approve \(id)"
}
} else {
Button("Copy: openclaw devices approve <requestId>") {
UIPasteboard.general.string = "openclaw devices approve <requestId>"
}
}
} header: {
Text("Pairing Approval")
} footer: {
Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
}
}
Section {
Button {
Task { await self.retryLastAttempt() }
} label: {
if self.connectingGatewayID == "retry" {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Retry Connection")
}
}
.disabled(self.connectingGatewayID != nil)
Button {
self.resumeAfterPairingApproval()
} label: {
Label("Resume After Approval", systemImage: "arrow.clockwise")
}
.disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
Button {
self.openQRScannerFromOnboarding()
} label: {
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
}
.disabled(self.connectingGatewayID != nil)
}
}
}
private var successStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.padding(.bottom, 20)
Text("Connected")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
let server = self.appModel.gatewayServerName ?? "gateway"
Text(server)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
if let addr = self.appModel.gatewayRemoteAddress {
Text(addr)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
self.onClose()
} label: {
Text("Open OpenClaw")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
@ViewBuilder
private func manualConnectionFieldsSection(title: String) -> some View {
Section(title) {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.manualHost = link.host
self.manualPort = link.port
self.manualTLS = link.tls
if let token = link.token {
self.gatewayToken = token
}
if let password = link.password {
self.gatewayPassword = password
}
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false
self.connectMessage = "Connecting via QR code…"
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)"
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
Task { await self.connectManual() }
}
private func openQRScannerFromOnboarding() {
// Stop active reconnect loops before scanning new credentials.
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
}
private func resumeAfterPairingApproval() {
// We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
self.appModel.gatewayAutoReconnectEnabled = true
self.appModel.gatewayPairingPaused = false
self.connectMessage = "Retrying after approval…"
self.statusLine = "Retrying after approval…"
Task { await self.retryLastAttempt() }
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(
ofType: CIDetectorTypeQRCode, context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
let features = detector?.features(in: ciImage) ?? []
for feature in features {
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
return message
}
}
return nil
}
private func navigateBack() {
guard let target = self.step.previous else { return }
self.connectingGatewayID = nil
self.connectMessage = nil
self.step = target
}
private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
}
private func initializeState() {
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
switch last {
case let .manual(host, port, useTLS, _):
self.manualHost = host
self.manualPort = port
self.manualTLS = useTLS
case .discovered:
self.manualHost = "openclaw.local"
self.manualPort = 18789
self.manualTLS = true
}
} else {
self.manualHost = "openclaw.local"
self.manualPort = 18789
self.manualTLS = true
}
}
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
if self.selectedMode == nil {
self.selectedMode = OnboardingStateStore.lastMode()
}
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
self.manualHost = "localhost"
self.manualTLS = false
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
self.didAutoPresentQR = true
self.statusLine = "No saved pairing found. Scan QR code to connect."
self.showQRScanner = true
}
}
private func scheduleDiscoveryRestart() {
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
self.gatewayController.restartDiscovery()
}
}
private func saveGatewayCredentials(token: String, password: String) {
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceId.isEmpty else { return }
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
}
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.issue = .none
self.connectMessage = "Connecting to \(gateway.name)"
self.statusLine = "Connecting to \(gateway.name)"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(gateway)
}
private func selectMode(_ mode: OnboardingConnectionMode) {
self.selectedMode = mode
self.applyModeDefaults(mode)
}
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
switch mode {
case .homeNetwork:
if hostIsDefaultLike { self.manualHost = "openclaw.local" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .remoteDomain:
if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
self.manualTLS = true
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
case .developerLocal:
if hostIsDefaultLike { self.manualHost = "localhost" }
self.manualTLS = false
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
}
}
private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !lanHost.isEmpty { return true }
let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !tailnetDns.isEmpty
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
self.connectingGatewayID = "manual"
self.issue = .none
self.connectMessage = "Connecting to \(host)"
self.statusLine = "Connecting to \(host):\(self.manualPort)"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
}
private func retryLastAttempt() async {
self.connectingGatewayID = "retry"
self.issue = .none
self.connectMessage = "Retrying…"
self.statusLine = "Retrying last connection…"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectLastKnown()
}
}
private struct OnboardingModeRow: View {
let title: String
let subtitle: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(.body.weight(.semibold))
Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,96 @@
import OpenClawKit
import SwiftUI
import VisionKit
struct QRScannerView: UIViewControllerRepresentable {
let onGatewayLink: (GatewayConnectDeepLink) -> Void
let onError: (String) -> Void
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
guard DataScannerViewController.isSupported else {
context.coordinator.reportError("QR scanning is not supported on this device.")
return UIViewController()
}
guard DataScannerViewController.isAvailable else {
context.coordinator.reportError("Camera scanning is currently unavailable.")
return UIViewController()
}
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
isHighlightingEnabled: true)
scanner.delegate = context.coordinator
do {
try scanner.startScanning()
} catch {
context.coordinator.reportError("Could not start QR scanner.")
}
return scanner
}
func updateUIViewController(_: UIViewController, context _: Context) {}
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
if let scanner = uiViewController as? DataScannerViewController {
scanner.stopScanning()
}
coordinator.parent.onDismiss()
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let parent: QRScannerView
private var handled = false
private var reportedError = false
init(parent: QRScannerView) {
self.parent = parent
}
func reportError(_ message: String) {
guard !self.reportedError else { return }
self.reportedError = true
Task { @MainActor in
self.parent.onError(message)
}
}
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
guard !self.handled else { return }
for item in items {
guard case let .barcode(barcode) = item,
let payload = barcode.payloadStringValue
else { continue }
// Try setup code format first (base64url JSON from /pair qr).
if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
self.handled = true
self.parent.onGatewayLink(link)
return
}
// Fall back to deep link URL format (openclaw://gateway?...).
if let url = URL(string: payload),
let route = DeepLinkParser.parse(url),
case let .gateway(link) = route
{
self.handled = true
self.parent.onGatewayLink(link)
return
}
}
}
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
func dataScanner(
_: DataScannerViewController,
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
{
self.reportError("Camera is not available on this device.")
}
}
}

View File

@@ -10,6 +10,7 @@ struct RootCanvas: View {
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@@ -19,6 +20,9 @@ struct RootCanvas: View {
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@State private var didEvaluateOnboarding: Bool = false
@State private var didAutoOpenSettings: Bool = false
private enum PresentedSheet: Identifiable {
@@ -35,6 +39,33 @@ struct RootCanvas: View {
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
// On first run or explicit launch onboarding state, onboarding always wins.
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
// Settings auto-open is a recovery path for previously-connected installs only.
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
var body: some View {
ZStack {
CanvasContent(
@@ -61,17 +92,35 @@ struct RootCanvas: View {
switch sheet {
case .settings:
SettingsTab()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
case .chat:
ChatSheet(
gateway: self.appModel.operatorSession,
// Mobile chat UI should use the node role RPC surface (chat.* / sessions.*)
// to avoid requiring operator scopes like operator.read.
gateway: self.appModel.gatewaySession,
sessionKey: self.appModel.mainSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
case .quickSetup:
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
}
}
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onClose: {
self.showOnboarding = false
})
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
@@ -81,11 +130,20 @@ struct RootCanvas: View {
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
}
}
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
self.hasConnectedOnce = true
OnboardingStateStore.markCompleted(mode: nil)
}
self.maybeAutoOpenSettings()
}
@@ -144,11 +202,31 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func shouldAutoOpenSettings() -> Bool {
if self.appModel.gatewayServerName != nil { return false }
if !self.hasConnectedOnce { return true }
if !self.onboardingComplete { return true }
return !self.hasExistingGatewayConfig()
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
self.showOnboarding = true
return
}
guard !self.didEvaluateOnboarding else { return }
self.didEvaluateOnboarding = true
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
break
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
}
private func hasExistingGatewayConfig() -> Bool {
@@ -159,13 +237,21 @@ struct RootCanvas: View {
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
guard self.shouldAutoOpenSettings() else { return }
guard !self.showOnboarding else { return }
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
private func maybeShowQuickSetup() {
guard !self.quickSetupDismissed else { return }
guard !self.showOnboarding else { return }
guard self.presentedSheet == nil else { return }
guard self.appModel.gatewayServerName == nil else { return }
guard !self.gatewayController.gateways.isEmpty else { return }
@@ -272,11 +358,64 @@ private struct CanvasContent: View {
}
private var statusActivity: StatusPill.Activity? {
StatusActivityBuilder.build(
appModel: self.appModel,
voiceWakeEnabled: self.voiceWakeEnabled,
cameraHUDText: self.cameraHUDText,
cameraHUDKind: self.cameraHUDKind)
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if self.appModel.isBackgrounded {
return StatusPill.Activity(
title: "Foreground required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
}
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
let systemImage: String
let tint: Color?
switch cameraHUDKind {
case .photo:
systemImage = "camera.fill"
tint = nil
case .recording:
systemImage = "video.fill"
tint = .red
case .success:
systemImage = "checkmark.circle.fill"
tint = .green
case .error:
systemImage = "exclamationmark.triangle.fill"
tint = .red
}
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
}
if self.voiceWakeEnabled {
let voiceStatus = self.appModel.voiceWake.statusText
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if self.appModel.talkMode.isEnabled {
return nil
}
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}
}
return nil
}
}

View File

@@ -76,4 +76,52 @@ import Testing
timeoutSeconds: nil,
key: nil)))
}
@Test func parseGatewayLinkParsesCommonFields() {
let url = URL(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
password: "pw"))
}
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
#expect(link == .init(
host: "gateway.example.com",
port: 443,
tls: true,
token: "tok",
password: nil))
}
}

View File

@@ -101,16 +101,9 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual:gateway.example.com:443")
stableID: "manual|gateway.example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
guard case let .manual(host, port, useTLS, stableID) = loaded else {
Issue.record("Expected manual last-gateway connection")
return
}
#expect(host == "gateway.example.com")
#expect(port == 443)
#expect(useTLS == true)
#expect(stableID == "manual:gateway.example.com:443")
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
}
}
@@ -120,7 +113,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
"gateway.last.host": "",
"gateway.last.port": 0,
"gateway.last.tls": false,
"gateway.last.stableID": "manual:bad:0",
"gateway.last.stableID": "manual|invalid|0",
]) {
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == nil)

View File

@@ -0,0 +1,33 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionIssueTests {
@Test func detectsTokenMissing() {
let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
#expect(issue == .tokenMissing)
#expect(issue.needsAuthToken)
}
@Test func detectsUnauthorized() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
#expect(issue == .unauthorized)
#expect(issue.needsAuthToken)
}
@Test func detectsPairingWithRequestId() {
let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
#expect(issue == .pairingRequired(requestId: "abc123"))
#expect(issue.needsPairing)
#expect(issue.requestId == "abc123")
}
@Test func detectsNetworkError() {
let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
#expect(issue == .network)
}
@Test func returnsNoneForBenignStatus() {
let issue = GatewayConnectionIssue.detect(from: "Connected")
#expect(issue == .none)
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OnboardingStateStoreTests {
@Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
@Test @MainActor func doesNotPresentWhenConnected() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = "gateway"
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
@Test @MainActor func markCompletedPersistsMode() {
let testDefaults = self.makeDefaults()
let defaults = testDefaults.defaults
defer { self.reset(testDefaults) }
let appModel = NodeAppModel()
appModel.gatewayServerName = nil
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
OnboardingStateStore.markIncomplete(defaults: defaults)
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
}
private struct TestDefaults {
var suiteName: String
var defaults: UserDefaults
}
private func makeDefaults() -> TestDefaults {
let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
return TestDefaults(
suiteName: suiteName,
defaults: UserDefaults(suiteName: suiteName) ?? .standard)
}
private func reset(_ defaults: TestDefaults) {
defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
}
}

View File

@@ -2,6 +2,56 @@ import Foundation
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
case gateway(GatewayConnectDeepLink)
}
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
public let host: String
public let port: Int
public let tls: Bool
public let token: String?
public let password: String?
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
self.host = host
self.port = port
self.tls = tls
self.token = token
self.password = password
}
public var websocketURL: URL? {
let scheme = self.tls ? "wss" : "ws"
return URL(string: "\(scheme)://\(self.host):\(self.port)")
}
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let urlString = json["url"] as? String,
let parsed = URLComponents(string: urlString),
let hostname = parsed.host, !hostname.isEmpty
else { return nil }
let scheme = (parsed.scheme ?? "ws").lowercased()
let tls = scheme == "wss"
let port = parsed.port ?? (tls ? 443 : 18789)
let token = json["token"] as? String
let password = json["password"] as? String
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
}
private static func decodeBase64Url(_ input: String) -> Data? {
var base64 = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = base64.count % 4
if remainder > 0 {
base64.append(contentsOf: String(repeating: "=", count: 4 - remainder))
}
return Data(base64Encoded: base64)
}
}
public struct AgentDeepLink: Codable, Sendable, Equatable {
@@ -69,6 +119,23 @@ public enum DeepLinkParser {
channel: query["channel"],
timeoutSeconds: timeoutSeconds,
key: query["key"]))
case "gateway":
guard let hostParam = query["host"],
!hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return nil
}
let port = query["port"].flatMap { Int($0) } ?? 18789
let tls = (query["tls"] as NSString?)?.boolValue ?? false
return .gateway(
.init(
host: hostParam,
port: port,
tls: tls,
token: query["token"],
password: query["password"]))
default:
return nil
}

View File

@@ -133,10 +133,16 @@ public actor GatewayChannelActor {
private var lastAuthSource: GatewayAuthSource = .none
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 3.0
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
// and we must include the nonce once the gateway requires v2 signing.
private let connectTimeoutSeconds: Double = 12
private let connectChallengeTimeoutSeconds: Double = 6.0
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
// but NATs/proxies often require outbound traffic to keep the connection alive.
private let keepaliveIntervalSeconds: Double = 15.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -175,6 +181,9 @@ public actor GatewayChannelActor {
self.tickTask?.cancel()
self.tickTask = nil
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
@@ -257,6 +266,7 @@ public actor GatewayChannelActor {
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
@@ -265,6 +275,29 @@ public actor GatewayChannelActor {
}
}
private func startKeepalive() {
self.keepaliveTask?.cancel()
self.keepaliveTask = Task { [weak self] in
guard let self else { return }
await self.keepaliveLoop()
}
}
private func keepaliveLoop() async {
while self.shouldReconnect {
try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000))
guard self.shouldReconnect else { return }
guard self.connected else { continue }
// Best-effort outbound message to keep intermediate NAT/proxy state alive.
// We intentionally ignore the response.
do {
try await self.send(method: "health", params: nil)
} catch {
// Avoid spamming logs; the reconnect paths will surface meaningful errors.
}
}
}
private func sendConnect() async throws {
let platform = InstanceIdentity.platformString
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
@@ -458,6 +491,8 @@ public actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
self.keepaliveTask?.cancel()
self.keepaliveTask = nil
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped)
await self.scheduleReconnect()

View File

@@ -1,6 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import os from "node:os";
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
import qrcode from "qrcode-terminal";
function renderQrAscii(data: string): Promise<string> {
return new Promise((resolve) => {
qrcode.generate(data, { small: true }, (output: string) => {
resolve(output);
});
});
}
const DEFAULT_GATEWAY_PORT = 18789;
@@ -434,6 +443,69 @@ export default function register(api: OpenClawPluginApi) {
password: auth.password,
};
if (action === "qr") {
const setupCode = encodeSetupCode(payload);
const qrAscii = await renderQrAscii(setupCode);
const authLabel = auth.label ?? "auth";
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
if (channel === "telegram" && target) {
try {
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
if (send) {
await send(
target,
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
"\n",
),
{
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
},
);
return {
text: [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, come back here and run `/pair approve` to complete pairing.",
].join("\n"),
};
}
} catch (err) {
api.logger.warn?.(
`device-pair: telegram QR send failed, falling back (${String(
(err as Error)?.message ?? err,
)})`,
);
}
}
// Render based on channel capability
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
const infoLines = [
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
"",
"After scanning, run `/pair approve` to complete pairing.",
];
// WebUI + CLI/TUI: ASCII QR
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
"```",
qrAscii,
"```",
"",
...infoLines,
].join("\n"),
};
}
const channel = ctx.channel;
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
const authLabel = auth.label ?? "auth";