iOS: restore onboarding wizard flow

This commit is contained in:
Mariano Belinky
2026-02-15 10:48:41 +00:00
committed by Mariano Belinky
parent bc05143e4e
commit 7cbaae15e0
7 changed files with 1105 additions and 12 deletions

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

@@ -340,6 +340,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 +352,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 +485,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(
@@ -1671,12 +1710,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 }
@@ -1777,6 +1817,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 {
@@ -1810,5 +1861,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,49 @@
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 }
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,737 @@
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 manualTLS: Bool = true
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var connectMessage: String?
@State private var connectingGatewayID: String?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@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)
}
}
}
}
.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.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.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)
self.issue = next
if self.step == .connect && (next.needsAuthToken || next.needsPairing) {
self.step = .auth
}
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.connectMessage = newValue
}
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
self.step = .success
if !self.didMarkCompleted, let selectedMode {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
}
}
@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.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(.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)
} 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", value: self.$manualPort, format: .number)
.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") {
if self.issue.needsAuthToken {
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
} else {
Text("Auth token looks valid.")
}
}
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("Run these commands on your gateway host to approve this device.")
}
}
Section {
Button {
Task { await self.retryLastAttempt() }
} label: {
if self.connectingGatewayID == "retry" {
ProgressView()
.progressViewStyle(.circular)
} else {
Text("Retry Connection")
}
}
.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", value: self.$manualPort, format: .number)
.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.showQRScanner = false
self.connectMessage = "Connecting via QR code…"
self.step = .connect
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
Task { await self.connectManual() }
}
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
}
}
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) ?? ""
}
}
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.connectMessage = "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.connectMessage = "Connecting to \(host)"
defer { self.connectingGatewayID = nil }
await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
}
private func retryLastAttempt() async {
self.connectingGatewayID = "retry"
self.connectMessage = "Retrying…"
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

@@ -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 {
@@ -71,16 +75,35 @@ struct RootCanvas: View {
GatewayQuickSetupSheet()
}
}
.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.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.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 {
@@ -144,6 +167,20 @@ struct RootCanvas: View {
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
self.showOnboarding = true
return
}
guard !self.didEvaluateOnboarding else { return }
self.didEvaluateOnboarding = true
guard OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel) else { return }
self.onboardingAllowSkip = true
self.showOnboarding = true
}
private func shouldAutoOpenSettings() -> Bool {
if self.appModel.gatewayServerName != nil { return false }
if !self.hasConnectedOnce { return true }
@@ -166,6 +203,7 @@ struct RootCanvas: View {
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 +310,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

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