From f061b65e1f93db2448fe25ffbfc40f1ff7d568cb Mon Sep 17 00:00:00 2001 From: David Proctor Date: Mon, 9 Feb 2026 10:16:18 -0700 Subject: [PATCH] iOS: fix gateway connect roles; improve discovery/connect UX; stabilize chat sending --- .../Chat/IOSGatewayChatTransport.swift | 20 ++- .../Gateway/GatewayConnectionController.swift | 161 +++++++++++++++++- .../Gateway/GatewayDiscoveryModel.swift | 11 +- .../Gateway/GatewayQuickSetupSheet.swift | 113 ++++++++++++ apps/ios/Sources/Model/NodeAppModel.swift | 2 + apps/ios/Sources/OpenClawApp.swift | 17 ++ apps/ios/Sources/RootCanvas.swift | 16 ++ apps/ios/Sources/Settings/SettingsTab.swift | 109 ++++++------ apps/ios/Sources/Voice/TalkModeManager.swift | 11 +- .../OpenClawChatUI/ChatViewModel.swift | 72 +++++++- .../Sources/OpenClawKit/GatewayChannel.swift | 8 +- 11 files changed, 453 insertions(+), 87 deletions(-) create mode 100644 apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift 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 34af7f1dc06..620cf0ccc75 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -56,23 +56,42 @@ final class GatewayConnectionController { } } - func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. + func connectWithDiagnostics(_ 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) - guard let host = self.resolveGatewayHost(gateway) else { return } - let port = gateway.gatewayPort ?? 18789 + let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway) - guard let url = self.buildGatewayURL( - host: host, - port: port, - useTLS: tlsParams?.required == true) - else { return } + let resolvedHost: String? + let resolvedPort: Int? + if let host = self.resolveGatewayHost(gateway) { + resolvedHost = host + resolvedPort = gateway.gatewayPort ?? 18789 + } else if let fallback = await self.resolveHostPortFromBonjourEndpoint(gateway.endpoint) { + resolvedHost = fallback.host + resolvedPort = fallback.port > 0 ? fallback.port : (gateway.gatewayPort ?? 18789) + } else { + return "Discovery found a gateway service, but no connectable host was advertised (missing lanHost/tailnetDns TXT) and the Bonjour service did not resolve to an address. Try Manual connect." + } + + guard let host = resolvedHost, let port = resolvedPort else { + return "Failed to resolve a connectable host/port from discovery." + } + + let useTLS = tlsParams?.required == true || gateway.tlsEnabled + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: useTLS) else { + return "Failed to build gateway URL (host=\(host) port=\(port) tls=\(useTLS))." + } + GatewaySettingsStore.saveLastGatewayConnection( host: host, port: port, - useTLS: tlsParams?.required == true, + useTLS: useTLS, stableID: gateway.stableID) self.didAutoConnect = true self.startAutoConnect( @@ -81,6 +100,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 { @@ -381,6 +405,125 @@ final class GatewayConnectionController { return nil } + 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/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index 223cfda5c90..4ad431c1874 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -75,7 +75,16 @@ final class GatewayDiscoveryModel { switch result.endpoint { case let .service(name, _, _, _): let decodedName = BonjourEscapes.decode(name) - let txt = result.endpoint.txtRecord?.dictionary ?? [:] + // Some iOS versions return TXT records via metadata, not endpoint.txtRecord. + var txt = result.endpoint.txtRecord?.dictionary ?? [:] + if txt.isEmpty { + switch result.metadata { + case let .bonjour(meta): + txt = meta.dictionary + default: + break + } + } let advertisedName = txt["displayName"] let prettyAdvertised = advertisedName .map(Self.prettifyInstanceName) 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/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d41a619aa26..4a8fbee624f 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -53,6 +53,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 d3da84cae8b..90e3ff86a64 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -4,6 +4,7 @@ import UIKit struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @Environment(VoiceWakeManager.self) private var voiceWake + @Environment(GatewayConnectionController.self) private var gatewayController @Environment(\.colorScheme) private var systemColorScheme @Environment(\.scenePhase) private var scenePhase @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @@ -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 } } } @@ -62,12 +66,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() } @@ -154,6 +162,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 6267f621c50..528dcfe9321 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -103,7 +103,6 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } - DisclosureGroup("Advanced") { if self.appModel.gatewayServerName == nil { LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) } @@ -148,67 +147,69 @@ 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) - Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) + TextField("Gateway Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() - TextField("Gateway Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + SecureField("Gateway Password", text: self.$gatewayPassword) - 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)) + 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: { @@ -418,10 +419,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) } @@ -507,7 +509,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.connectStatus.text = err + } } private func connectLastKnown() async { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 0400fd28843..6cccc81f02d 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -814,23 +814,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/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 272fd81c11d..81de05f15ec 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -184,7 +184,7 @@ public final class OpenClawChatViewModel { let decoded = raw.compactMap { item in (try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self)) } - return Self.dedupeMessages(decoded) + return Self.filterHeartbeatNoise(Self.dedupeMessages(decoded)) } private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { @@ -205,6 +205,56 @@ public final class OpenClawChatViewModel { return result } + private static func filterHeartbeatNoise(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { + messages.filter { !Self.isHeartbeatNoiseMessage($0) } + } + + private static func isHeartbeatNoiseMessage(_ message: OpenClawChatMessage) -> Bool { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return false } + + if role == "assistant", Self.isHeartbeatAckText(text) { + return true + } + if role == "user", Self.isHeartbeatPollText(text) { + return true + } + // Some models occasionally echo the heartbeat prompt text as an assistant reply. + if role == "assistant", Self.isHeartbeatPollText(text) { + return true + } + return false + } + + private static func isHeartbeatPollText(_ text: String) -> Bool { + let lower = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Match the default heartbeat prompt without requiring the entire multi-sentence string. + return lower.hasPrefix("read heartbeat.md if it exists") + } + + private static func isHeartbeatAckText(_ text: String) -> Bool { + // Heartbeat acks are intended to be internal. Treat common markup wrappers as equivalent. + var t = text.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { return false } + + // Strip a few common wrappers (markdown/HTML) so **HEARTBEAT_OK** or HEARTBEAT_OK still matches. + let wrappers = ["**", "__", "`", "", "", "", ""] + for w in wrappers { + t = t.replacingOccurrences(of: w, with: "") + } + t = t.trimmingCharacters(in: .whitespacesAndNewlines) + + // Allow a tiny amount of padding (some channels append a marker/emoji). + if t == "HEARTBEAT_OK" { return true } + if t.hasPrefix("HEARTBEAT_OK") { + let rest = t.dropFirst("HEARTBEAT_OK".count) + return rest.trimmingCharacters(in: .whitespacesAndNewlines).count <= 10 + } + return false + } + private static func dedupeKey(for message: OpenClawChatMessage) -> String? { guard let timestamp = message.timestamp else { return nil } let text = message.content.compactMap(\.text).joined(separator: "\n") @@ -214,14 +264,22 @@ public final class OpenClawChatViewModel { } private func performSend() async { - guard !self.isSending else { return } - let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } - - guard self.healthOK else { - self.errorText = "Gateway health not OK; cannot send" + if self.isSending { + chatUILogger.info("performSend ignored: already sending") return } + let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty && self.attachments.isEmpty { + chatUILogger.info("performSend ignored: empty input and no attachments") + return + } + + // Health checks are best-effort. If they fail (or the gateway doesn't implement them), + // we still attempt to send and let the RPC result determine success. + if !self.healthOK { + self.errorText = "Gateway health unknown; attempting send anyway" + } + chatUILogger.info("performSend sending len=\(trimmed.count, privacy: .public) attachments=\(self.attachments.count, privacy: .public) sessionKey=\(self.sessionKey, privacy: .public)") self.isSending = true self.errorText = nil diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index a255fc7a81d..70e70e3c897 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -399,8 +399,12 @@ public actor GatewayChannelActor { role: String ) async throws { if res.ok == false { - let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" - throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg]) + let code = res.error?["code"]?.value as? String + let msg = res.error?["message"]?.value as? String + let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in + acc[pair.key] = AnyCodable(pair.value.value) + } + throw GatewayResponseError(method: "connect", code: code, message: msg, details: details) } guard let payload = res.payload else { throw NSError(