mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
71
apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
Normal file
71
apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
52
apps/ios/Sources/Onboarding/OnboardingStateStore.swift
Normal file
52
apps/ios/Sources/Onboarding/OnboardingStateStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
852
apps/ios/Sources/Onboarding/OnboardingWizardView.swift
Normal file
852
apps/ios/Sources/Onboarding/OnboardingWizardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
96
apps/ios/Sources/Onboarding/QRScannerView.swift
Normal file
96
apps/ios/Sources/Onboarding/QRScannerView.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal file
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
57
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal file
57
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user