diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cb8a430d8..9463ad96dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. - 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. ## 2026.2.15 diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 3c828551ada..9571839059d 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -2,8 +2,10 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Foundation +import OSLog struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { + private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport") private let gateway: GatewayNodeSession init(gateway: GatewayNodeSession) { @@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { } func setActiveSessionKey(_ sessionKey: String) async throws { - struct Subscribe: Codable { var sessionKey: String } - let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) - let json = String(data: data, encoding: .utf8) - await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json) + // Operator clients receive chat events without node-style subscriptions. + // (chat.subscribe is a node event, not an operator RPC method.) } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { @@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { + Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)") struct Params: Codable { var sessionKey: String var message: String @@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { idempotencyKey: idempotencyKey) let data = try JSONEncoder().encode(params) let json = String(data: data, encoding: .utf8) - let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) - return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) + do { + let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) + Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") + return decoded + } catch { + Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + throw error + } } func requestHealth(timeoutMs: Int) async throws -> Bool { diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 995e2f36d04..132b32d364c 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -72,32 +72,55 @@ final class GatewayConnectionController { } } - func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + func allowAutoConnectAgain() { + self.didAutoConnect = false + self.maybeAutoConnect() + } + + func restartDiscovery() { + self.discovery.stop() + self.didAutoConnect = false + self.discovery.start() + self.updateFromDiscovery() + } + + + /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. + func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { await self.connectDiscoveredGateway(gateway) } private func connectDiscoveredGateway( - _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async + _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if instanceId.isEmpty { + return "Missing instanceId (node.instanceId). Try restarting the app." + } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. - guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return } + guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { + return "Failed to resolve the discovered gateway endpoint." + } let stableID = gateway.stableID // Discovery is a LAN operation; refuse unauthenticated plaintext connects. let tlsRequired = true let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - guard gateway.tlsEnabled || stored != nil else { return } + guard gateway.tlsEnabled || stored != nil else { + return "Discovered gateway is missing TLS and no trusted fingerprint is stored." + } if tlsRequired, stored == nil { guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) - else { return } - guard let fp = await self.probeTLSFingerprint(url: url) else { return } + else { return "Failed to build TLS URL for trust verification." } + guard let fp = await self.probeTLSFingerprint(url: url) else { + return "Failed to read TLS fingerprint from discovered gateway." + } self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false) self.pendingTrustPrompt = TrustPrompt( stableID: stableID, @@ -107,7 +130,7 @@ final class GatewayConnectionController { fingerprintSha256: fp, isManual: false) self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" - return + return nil } let tlsParams = stored.map { fp in @@ -118,7 +141,7 @@ final class GatewayConnectionController { host: target.host, port: target.port, useTLS: tlsParams?.required == true) - else { return } + else { return "Failed to build discovered gateway URL." } GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true) self.didAutoConnect = true self.startAutoConnect( @@ -127,6 +150,11 @@ final class GatewayConnectionController { tls: tlsParams, token: token, password: password) + return nil + } + + func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + _ = await self.connectWithDiagnostics(gateway) } func connectManual(host: String, port: Int, useTLS: Bool) async { @@ -490,6 +518,125 @@ final class GatewayConnectionController { } } + private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { + switch endpoint { + case let .hostPort(host, port): + return (host: host.debugDescription, port: Int(port.rawValue)) + case let .service(name, type, domain, _): + return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) + default: + return nil + } + } + + private static func resolveBonjourServiceToHostPort( + name: String, + type: String, + domain: String, + timeoutSeconds: TimeInterval = 3.0 + ) async -> (host: String, port: Int)? { + // NetService callbacks are delivered via a run loop. If we resolve from a thread without one, + // we can end up never receiving callbacks, which in turn leaks the continuation and leaves + // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always + // resume the continuation exactly once (timeout/cancel safe). + @MainActor + final class Resolver: NSObject, @preconcurrency NetServiceDelegate { + private var cont: CheckedContinuation<(host: String, port: Int)?, Never>? + private let service: NetService + private var timeoutTask: Task? + private var finished = false + + init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) { + self.cont = cont + self.service = service + super.init() + } + + func start(timeoutSeconds: TimeInterval) { + self.service.delegate = self + self.service.schedule(in: .main, forMode: .default) + + // NetService has its own timeout, but we keep a manual one as a backstop in case + // callbacks never arrive (e.g. local network permission issues). + self.timeoutTask = Task { @MainActor [weak self] in + guard let self else { return } + let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: ns) + self.finish(nil) + } + + self.service.resolve(withTimeout: timeoutSeconds) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + self.finish(Self.extractHostPort(sender)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + _ = errorDict // currently best-effort; callers surface a generic failure + self.finish(nil) + } + + private func finish(_ result: (host: String, port: Int)?) { + guard !self.finished else { return } + self.finished = true + + self.timeoutTask?.cancel() + self.timeoutTask = nil + + self.service.stop() + self.service.remove(from: .main, forMode: .default) + + let c = self.cont + self.cont = nil + c?.resume(returning: result) + } + + private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? { + let port = svc.port + + if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { + return (host: host, port: port) + } + + guard let addrs = svc.addresses else { return nil } + for addrData in addrs { + let host = addrData.withUnsafeBytes { ptr -> String? in + guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + let rc = getnameinfo( + base.assumingMemoryBound(to: sockaddr.self), + socklen_t(ptr.count), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard rc == 0 else { return nil } + return String(cString: buffer) + } + + if let host, !host.isEmpty { + return (host: host, port: port) + } + } + + return nil + } + } + + return await withCheckedContinuation { cont in + Task { @MainActor in + let service = NetService(domain: domain, type: type, name: name) + let resolver = Resolver(cont: cont, service: service) + // Keep the resolver alive for the lifetime of the NetService resolve. + objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + resolver.start(timeoutSeconds: timeoutSeconds) + } + } + } + private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { let scheme = useTLS ? "wss" : "ws" var components = URLComponents() diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift new file mode 100644 index 00000000000..eac92df71e8 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct GatewayQuickSetupSheet: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController + @Environment(\.dismiss) private var dismiss + + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false + @State private var connecting: Bool = false + @State private var connectError: String? + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text("Connect to a Gateway?") + .font(.title2.bold()) + + if let candidate = self.bestCandidate { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: candidate.name) + .font(.headline) + Text(verbatim: candidate.debugID) + .font(.footnote) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + // Use verbatim strings so Bonjour-provided values can't be interpreted as + // localized format strings (which can crash with Objective-C exceptions). + Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)") + Text(verbatim: "Status: \(self.appModel.gatewayStatusText)") + Text(verbatim: "Node: \(self.appModel.nodeStatusText)") + Text(verbatim: "Operator: \(self.appModel.operatorStatusText)") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 14)) + + Button { + self.connectError = nil + self.connecting = true + Task { + let err = await self.gatewayController.connectWithDiagnostics(candidate) + await MainActor.run { + self.connecting = false + self.connectError = err + // If we kicked off a connect, leave the sheet up so the user can see status evolve. + } + } + } label: { + Group { + if self.connecting { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(self.connecting) + + if let connectError { + Text(connectError) + .font(.footnote) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + Button { + self.dismiss() + } label: { + Text("Not now") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(self.connecting) + + Toggle("Don’t show this again", isOn: self.$quickSetupDismissed) + .padding(.top, 4) + } else { + Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.") + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding() + .navigationTitle("Quick Setup") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.quickSetupDismissed = true + self.dismiss() + } label: { + Text("Close") + } + } + } + } + } + + private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? { + // Prefer whatever discovery says is first; the list is already name-sorted. + self.gatewayController.gateways.first + } +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index fa4d2953d68..3ff57ad2e67 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -207,6 +207,25 @@ enum GatewaySettingsStore { return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) } + static func clearLastGatewayConnection(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func deleteGatewayCredentials(instanceId: String) { + let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: trimmed)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: trimmed)) + } + static func loadGatewayClientIdOverride(stableID: String) -> String? { let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedID.isEmpty else { return nil } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 0ca521ccc60..5a1a055d8aa 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -10,7 +10,6 @@ import UserNotifications private struct NotificationCallError: Error, Sendable { let message: String } - // Ensures notification requests return promptly even if the system prompt blocks. private final class NotificationInvokeLatch: @unchecked Sendable { private let lock = NSLock() @@ -37,7 +36,6 @@ private final class NotificationInvokeLatch: @unchecked Sendable { cont?.resume(returning: response) } } - @MainActor @Observable final class NodeAppModel { @@ -53,6 +51,8 @@ final class NodeAppModel { private let camera: any CameraServicing private let screenRecorder: any ScreenRecordingServicing var gatewayStatusText: String = "Offline" + var nodeStatusText: String = "Offline" + var operatorStatusText: String = "Offline" var gatewayServerName: String? var gatewayRemoteAddress: String? var connectedGatewayID: String? diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 8ad23ae20a1..d180e1fc4d9 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import Foundation @main struct OpenClawApp: App { @@ -7,6 +8,7 @@ struct OpenClawApp: App { @Environment(\.scenePhase) private var scenePhase init() { + Self.installUncaughtExceptionLogger() GatewaySettingsStore.bootstrapPersistence() let appModel = NodeAppModel() _appModel = State(initialValue: appModel) @@ -29,3 +31,18 @@ struct OpenClawApp: App { } } } + +extension OpenClawApp { + private static func installUncaughtExceptionLogger() { + NSLog("OpenClaw: installing uncaught exception handler") + NSSetUncaughtExceptionHandler { exception in + // Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not + // produce a normal Swift error backtrace. + let reason = exception.reason ?? "(no reason)" + NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason) + for line in exception.callStackSymbols { + NSLog(" %@", line) + } + } + } +} diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index c8f13eef407..ec6fea57843 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -3,6 +3,7 @@ import UIKit struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController @Environment(VoiceWakeManager.self) private var voiceWake @Environment(\.colorScheme) private var systemColorScheme @Environment(\.scenePhase) private var scenePhase @@ -14,6 +15,7 @@ struct RootCanvas: View { @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false @State private var presentedSheet: PresentedSheet? @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? @@ -22,11 +24,13 @@ struct RootCanvas: View { private enum PresentedSheet: Identifiable { case settings case chat + case quickSetup var id: Int { switch self { case .settings: 0 case .chat: 1 + case .quickSetup: 2 } } } @@ -63,12 +67,16 @@ struct RootCanvas: View { sessionKey: self.appModel.mainSessionKey, agentName: self.appModel.activeAgentName, userAccent: self.appModel.seamColor) + case .quickSetup: + GatewayQuickSetupSheet() } } .onAppear { self.updateIdleTimer() } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .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() } @@ -155,6 +163,14 @@ struct RootCanvas: View { self.didAutoOpenSettings = true self.presentedSheet = .settings } + + private func maybeShowQuickSetup() { + guard !self.quickSetupDismissed else { return } + guard self.presentedSheet == nil else { return } + guard self.appModel.gatewayServerName == nil else { return } + guard !self.gatewayController.gateways.isEmpty else { return } + self.presentedSheet = .quickSetup + } } private struct CanvasContent: View { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 14e01fa0692..0f8e461d7d2 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -28,6 +28,12 @@ struct SettingsTab: View { @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true @AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false + + // Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard). + @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 + @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false + @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + @State private var connectingGatewayID: String? @State private var localIPAddress: String? @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue @@ -40,6 +46,9 @@ struct SettingsTab: View { @State private var gatewayExpanded: Bool = true @State private var selectedAgentPickerId: String = "" + @State private var showResetOnboardingAlert: Bool = false + @State private var suppressCredentialPersist: Bool = false + private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings") var body: some View { @@ -104,7 +113,6 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } - DisclosureGroup("Advanced") { if self.appModel.gatewayServerName == nil { LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) } @@ -149,69 +157,74 @@ struct SettingsTab: View { self.gatewayList(showing: .all) } - Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) + DisclosureGroup("Advanced") { + Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) - TextField("Host", text: self.$manualGatewayHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + TextField("Host", text: self.$manualGatewayHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() - TextField("Port (optional)", text: self.manualPortBinding) - .keyboardType(.numberPad) + TextField("Port (optional)", text: self.manualPortBinding) + .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualGatewayTLS) + Toggle("Use TLS", isOn: self.$manualGatewayTLS) - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect (Manual)") } - } else { - Text("Connect (Manual)") } - } - .disabled(self.connectingGatewayID != nil || self.manualGatewayHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || !self.manualPortIsValid) + .disabled(self.connectingGatewayID != nil || self.manualGatewayHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty || !self.manualPortIsValid) - Text( - "Use this when mDNS/Bonjour discovery is blocked. " - + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") - .font(.footnote) - .foregroundStyle(.secondary) + Text( + "Use this when mDNS/Bonjour discovery is blocked. " + + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") + .font(.footnote) + .foregroundStyle(.secondary) - Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) - .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in - self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) + Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) + .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in + self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) + } + + NavigationLink("Discovery Logs") { + GatewayDiscoveryDebugLogView() } - NavigationLink("Discovery Logs") { - GatewayDiscoveryDebugLogView() + Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) + + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway Password", text: self.$gatewayPassword) + + Button("Reset Onboarding", role: .destructive) { + self.showResetOnboardingAlert = true + } + + VStack(alignment: .leading, spacing: 6) { + Text("Debug") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + Text(self.gatewayDebugText()) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } } - - Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) - - TextField("Gateway Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - SecureField("Gateway Password", text: self.$gatewayPassword) - - VStack(alignment: .leading, spacing: 6) { - Text("Debug") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - Text(self.gatewayDebugText()) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - } - } } label: { HStack(spacing: 10) { Circle() @@ -310,6 +323,15 @@ struct SettingsTab: View { .accessibilityLabel("Close") } } + .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { + Button("Reset", role: .destructive) { + self.resetOnboarding() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.") + } .onAppear { self.localIPAddress = NetworkInterfaces.primaryIPv4Address() self.lastLocationModeRaw = self.locationEnabledModeRaw @@ -339,12 +361,14 @@ struct SettingsTab: View { GatewaySettingsStore.savePreferredGatewayStableID(trimmed) } .onChange(of: self.gatewayToken) { _, newValue in + guard !self.suppressCredentialPersist else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) } .onChange(of: self.gatewayPassword) { _, newValue in + guard !self.suppressCredentialPersist else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } @@ -432,10 +456,11 @@ struct SettingsTab: View { ForEach(rows) { gateway in HStack { VStack(alignment: .leading, spacing: 2) { - Text(gateway.name) + // Avoid localized-string formatting edge cases from Bonjour-advertised names. + Text(verbatim: gateway.name) let detailLines = self.gatewayDetailLines(gateway) ForEach(detailLines, id: \.self) { line in - Text(line) + Text(verbatim: line) .font(.footnote) .foregroundStyle(.secondary) } @@ -521,7 +546,10 @@ struct SettingsTab: View { GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) defer { self.connectingGatewayID = nil } - await self.gatewayController.connect(gateway) + let err = await self.gatewayController.connectWithDiagnostics(gateway) + if let err { + self.setupStatusText = err + } } private func connectLastKnown() async { @@ -860,6 +888,43 @@ struct SettingsTab: View { SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) } + private func resetOnboarding() { + // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.setupStatusText = nil + self.setupCode = "" + self.gatewayAutoConnect = false + + self.suppressCredentialPersist = true + defer { self.suppressCredentialPersist = false } + + self.gatewayToken = "" + self.gatewayPassword = "" + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). + GatewaySettingsStore.clearLastGatewayConnection() + + // RootCanvas also short-circuits onboarding when these are true. + self.onboardingComplete = false + self.hasConnectedOnce = false + + // Clear manual override so it doesn't count as an existing gateway config. + self.manualGatewayEnabled = false + self.manualGatewayHost = "" + + // Force re-present even without app restart. + self.onboardingRequestID += 1 + + // The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show. + self.dismiss() + } + private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { var lines: [String] = [] if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 86aacd2bb4f..fae5c28be4f 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -815,23 +815,14 @@ final class TalkModeManager: NSObject { private func subscribeChatIfNeeded(sessionKey: String) async { let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } - guard let gateway else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return } - let payload = "{\"sessionKey\":\"\(key)\"}" - await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload) + // Operator clients receive chat events without node-style subscriptions. self.chatSubscribedSessionKeys.insert(key) - self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") } private func unsubscribeAllChats() async { - guard let gateway else { return } - let keys = self.chatSubscribedSessionKeys self.chatSubscribedSessionKeys.removeAll() - for key in keys { - let payload = "{\"sessionKey\":\"\(key)\"}" - await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) - } } private func buildPrompt(transcript: String) -> String { diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 0d3bdbba0ee..6c2046f0817 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -76,4 +76,54 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(commands.contains(OpenClawLocationCommand.get.rawValue)) } } + @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() { + withUserDefaults([ + "node.instanceId": "ios-test", + "camera.enabled": true, + "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + // iOS should expose notify, but not host shell/exec-approval commands. + #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.run.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.which.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue)) + } + } + + @Test @MainActor func loadLastConnectionReadsSavedValues() { + withUserDefaults([:]) { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "gateway.example.com", + port: 443, + useTLS: true, + 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") + } + } + + @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { + withUserDefaults([ + "gateway.last.kind": "manual", + "gateway.last.host": "", + "gateway.last.port": 0, + "gateway.last.tls": false, + "gateway.last.stableID": "manual:bad:0", + ]) { + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == nil) + } + } }