From c23ccee6ad0cabd846b19de3ea730958fd06489f Mon Sep 17 00:00:00 2001 From: abdaraxus Date: Mon, 26 Jan 2026 08:03:56 +0000 Subject: [PATCH 1/2] fix: route iOS chat via operator session --- apps/ios/Sources/Chat/ChatSheet.swift | 2 +- .../Chat/IOSGatewayChatTransport.swift | 5 +- .../Gateway/GatewayConnectionController.swift | 23 +++++- apps/ios/Sources/Model/NodeAppModel.swift | 80 +++++++++++++++++-- apps/ios/Sources/RootCanvas.swift | 2 +- apps/ios/Sources/Settings/SettingsTab.swift | 1 + apps/ios/Sources/Voice/TalkModeManager.swift | 6 +- 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index c0e5593ff..3c4b29c76 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -8,7 +8,7 @@ struct ChatSheet: View { private let userAccent: Color? init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { - let transport = IOSGatewayChatTransport(gateway: gateway) + let transport = IOSGatewayChatTransport(gateway: gateway, supportsChatSubscribe: false) self._viewModel = State( initialValue: ClawdbotChatViewModel( sessionKey: sessionKey, diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index f7ee4aa79..1bb581b40 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -5,9 +5,11 @@ import Foundation struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable { private let gateway: GatewayNodeSession + private let supportsChatSubscribe: Bool - init(gateway: GatewayNodeSession) { + init(gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) { self.gateway = gateway + self.supportsChatSubscribe = supportsChatSubscribe } func abortRun(sessionKey: String, runId: String) async throws { @@ -33,6 +35,7 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable { } func setActiveSessionKey(_ sessionKey: String) async throws { + guard self.supportsChatSubscribe else { return } struct Subscribe: Codable { var sessionKey: String } let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let json = String(data: data, encoding: .utf8) diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 0f1cd02cf..5dc7f1afa 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -205,7 +205,8 @@ final class GatewayConnectionController { password: String?) { guard let appModel else { return } - let connectOptions = self.makeConnectOptions() + let nodeConnectOptions = self.makeNodeConnectOptions() + let operatorConnectOptions = self.makeOperatorConnectOptions() Task { [weak self] in guard let self else { return } @@ -218,7 +219,8 @@ final class GatewayConnectionController { tls: tls, token: token, password: password, - connectOptions: connectOptions) + nodeConnectOptions: nodeConnectOptions, + operatorConnectOptions: operatorConnectOptions) } } @@ -273,7 +275,7 @@ final class GatewayConnectionController { "manual|\(host.lowercased())|\(port)" } - private func makeConnectOptions() -> GatewayConnectOptions { + private func makeNodeConnectOptions() -> GatewayConnectOptions { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) @@ -288,6 +290,21 @@ final class GatewayConnectionController { clientDisplayName: displayName) } + private func makeOperatorConnectOptions() -> GatewayConnectOptions { + let defaults = UserDefaults.standard + let displayName = self.resolvedDisplayName(defaults: defaults) + + return GatewayConnectOptions( + role: "operator", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "clawdbot-ios", + clientMode: "ui", + clientDisplayName: displayName) + } + private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 2830f17d7..189ca23c2 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -19,6 +19,7 @@ final class NodeAppModel { let camera = CameraController() private let screenRecorder = ScreenRecordService() var gatewayStatusText: String = "Offline" + var operatorGatewayStatusText: String = "Offline" var gatewayServerName: String? var gatewayRemoteAddress: String? var connectedGatewayID: String? @@ -26,7 +27,9 @@ final class NodeAppModel { var mainSessionKey: String = "main" private let gateway = GatewayNodeSession() + private let operatorGateway = GatewayNodeSession() private var gatewayTask: Task? + private var operatorGatewayTask: Task? private var voiceWakeSyncTask: Task? @ObservationIgnored private var cameraHUDDismissTask: Task? let voiceWake = VoiceWakeManager() @@ -36,6 +39,7 @@ final class NodeAppModel { private var gatewayConnected = false var gatewaySession: GatewayNodeSession { self.gateway } + var operatorGatewaySession: GatewayNodeSession { self.operatorGateway } var cameraHUDText: String? var cameraHUDKind: CameraHUDKind? @@ -55,7 +59,7 @@ final class NodeAppModel { let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) - self.talkMode.attachGateway(self.gateway) + self.talkMode.attachGateway(self.operatorGateway, supportsChatSubscribe: false) let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") self.talkMode.setEnabled(talkEnabled) @@ -209,9 +213,13 @@ final class NodeAppModel { tls: GatewayTLSParams?, token: String?, password: String?, - connectOptions: GatewayConnectOptions) + nodeConnectOptions: GatewayConnectOptions, + operatorConnectOptions: GatewayConnectOptions) { self.gatewayTask?.cancel() + self.gatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil self.gatewayServerName = nil self.gatewayRemoteAddress = nil let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) @@ -219,7 +227,8 @@ final class NodeAppModel { self.gatewayConnected = false self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } + let nodeSessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } + let operatorSessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } self.gatewayTask = Task { var attempt = 0 @@ -239,8 +248,8 @@ final class NodeAppModel { url: url, token: token, password: password, - connectOptions: connectOptions, - sessionBox: sessionBox, + connectOptions: nodeConnectOptions, + sessionBox: nodeSessionBox, onConnected: { [weak self] in guard let self else { return } await MainActor.run { @@ -299,6 +308,7 @@ final class NodeAppModel { await MainActor.run { self.gatewayStatusText = "Offline" + self.operatorGatewayStatusText = "Offline" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil @@ -311,15 +321,75 @@ final class NodeAppModel { self.showLocalCanvasOnDisconnect() } } + + self.operatorGatewayTask = Task { + var attempt = 0 + while !Task.isCancelled { + await MainActor.run { + if attempt == 0 { + self.operatorGatewayStatusText = "Connecting…" + } else { + self.operatorGatewayStatusText = "Reconnecting…" + } + } + + do { + try await self.operatorGateway.connect( + url: url, + token: token, + password: password, + connectOptions: operatorConnectOptions, + sessionBox: operatorSessionBox, + onConnected: { [weak self] in + await MainActor.run { + self?.operatorGatewayStatusText = "Connected" + } + }, + onDisconnected: { [weak self] reason in + await MainActor.run { + self?.operatorGatewayStatusText = "Disconnected: \(reason)" + } + }, + onInvoke: { req in + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: ClawdbotNodeError( + code: .unavailable, + message: "UNAVAILABLE: operator session does not handle invokes")) + }) + + if Task.isCancelled { break } + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + if Task.isCancelled { break } + attempt += 1 + await MainActor.run { + self.operatorGatewayStatusText = "Gateway error: \(error.localizedDescription)" + } + let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) + try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + } + } + + await MainActor.run { + self.operatorGatewayStatusText = "Offline" + } + } } func disconnectGateway() { self.gatewayTask?.cancel() self.gatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil Task { await self.gateway.disconnect() } + Task { await self.operatorGateway.disconnect() } self.gatewayStatusText = "Offline" + self.operatorGatewayStatusText = "Offline" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 93cb81627..54a7d9197 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -52,7 +52,7 @@ struct RootCanvas: View { SettingsTab() case .chat: ChatSheet( - gateway: self.appModel.gatewaySession, + gateway: self.appModel.operatorGatewaySession, sessionKey: self.appModel.mainSessionKey, userAccent: self.appModel.seamColor) } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 431761617..e828fb370 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -67,6 +67,7 @@ struct SettingsTab: View { Section("Gateway") { LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Operator Status", value: self.appModel.operatorGatewayStatusText) if let serverName = self.appModel.gatewayServerName { LabeledContent("Server", value: serverName) if let addr = self.appModel.gatewayRemoteAddress { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 16ae245eb..d019e8494 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -44,14 +44,16 @@ final class TalkModeManager: NSObject { var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared private var gateway: GatewayNodeSession? + private var supportsChatSubscribe: Bool = true private let silenceWindow: TimeInterval = 0.7 private var chatSubscribedSessionKeys = Set() private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") - func attachGateway(_ gateway: GatewayNodeSession) { + func attachGateway(_ gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) { self.gateway = gateway + self.supportsChatSubscribe = supportsChatSubscribe } func updateMainSessionKey(_ sessionKey: String?) { @@ -296,6 +298,7 @@ final class TalkModeManager: NSObject { } private func subscribeChatIfNeeded(sessionKey: String) async { + guard self.supportsChatSubscribe else { return } let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } guard let gateway else { return } @@ -308,6 +311,7 @@ final class TalkModeManager: NSObject { } private func unsubscribeAllChats() async { + guard self.supportsChatSubscribe else { return } guard let gateway else { return } let keys = self.chatSubscribedSessionKeys self.chatSubscribedSessionKeys.removeAll() From 299752dde5d2e910b605626142e2e9cb3e82a075 Mon Sep 17 00:00:00 2001 From: abdaraxus Date: Mon, 26 Jan 2026 08:10:20 +0000 Subject: [PATCH 2/2] fix: allow node role to access chat methods for iOS Expands NODE_ROLE_METHODS to include chat.history, chat.send, chat.abort, and sessions.list so that iOS/mobile apps can access chat functionality when connected as a node device. --- src/gateway/server-methods.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 48bf32b59..bf2e85ea4 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -32,7 +32,16 @@ const APPROVALS_SCOPE = "operator.approvals"; const PAIRING_SCOPE = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); -const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); +const NODE_ROLE_METHODS = new Set([ + "node.invoke.result", + "node.event", + "skills.bins", + // Allow node role to access chat for iOS/mobile chat UI + "chat.history", + "chat.send", + "chat.abort", + "sessions.list", +]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list",