diff --git a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift new file mode 100644 index 00000000000..56d490e226b --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift @@ -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[.. 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 diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift new file mode 100644 index 00000000000..193fdbf792d --- /dev/null +++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -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) + } +} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift new file mode 100644 index 00000000000..6facec460a0 --- /dev/null +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -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? + @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 ") { + UIPasteboard.general.string = "openclaw devices approve " + } + } + } 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) + } +} diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index ec6fea57843..2801d726dc9 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -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? + @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 } } diff --git a/apps/ios/Tests/GatewayConnectionIssueTests.swift b/apps/ios/Tests/GatewayConnectionIssueTests.swift new file mode 100644 index 00000000000..8eb63f268ba --- /dev/null +++ b/apps/ios/Tests/GatewayConnectionIssueTests.swift @@ -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) + } +} diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift new file mode 100644 index 00000000000..30c014647b6 --- /dev/null +++ b/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -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) + } +}