fix: route iOS chat via operator session

This commit is contained in:
abdaraxus 2026-01-26 08:03:56 +00:00
parent bcedeb4e1f
commit c23ccee6ad
7 changed files with 107 additions and 12 deletions

View File

@ -8,7 +8,7 @@ struct ChatSheet: View {
private let userAccent: Color? private let userAccent: Color?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway) let transport = IOSGatewayChatTransport(gateway: gateway, supportsChatSubscribe: false)
self._viewModel = State( self._viewModel = State(
initialValue: ClawdbotChatViewModel( initialValue: ClawdbotChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,

View File

@ -5,9 +5,11 @@ import Foundation
struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable { struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
private let gateway: GatewayNodeSession private let gateway: GatewayNodeSession
private let supportsChatSubscribe: Bool
init(gateway: GatewayNodeSession) { init(gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) {
self.gateway = gateway self.gateway = gateway
self.supportsChatSubscribe = supportsChatSubscribe
} }
func abortRun(sessionKey: String, runId: String) async throws { func abortRun(sessionKey: String, runId: String) async throws {
@ -33,6 +35,7 @@ struct IOSGatewayChatTransport: ClawdbotChatTransport, Sendable {
} }
func setActiveSessionKey(_ sessionKey: String) async throws { func setActiveSessionKey(_ sessionKey: String) async throws {
guard self.supportsChatSubscribe else { return }
struct Subscribe: Codable { var sessionKey: String } struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)

View File

@ -205,7 +205,8 @@ final class GatewayConnectionController {
password: String?) password: String?)
{ {
guard let appModel else { return } guard let appModel else { return }
let connectOptions = self.makeConnectOptions() let nodeConnectOptions = self.makeNodeConnectOptions()
let operatorConnectOptions = self.makeOperatorConnectOptions()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@ -218,7 +219,8 @@ final class GatewayConnectionController {
tls: tls, tls: tls,
token: token, token: token,
password: password, password: password,
connectOptions: connectOptions) nodeConnectOptions: nodeConnectOptions,
operatorConnectOptions: operatorConnectOptions)
} }
} }
@ -273,7 +275,7 @@ final class GatewayConnectionController {
"manual|\(host.lowercased())|\(port)" "manual|\(host.lowercased())|\(port)"
} }
private func makeConnectOptions() -> GatewayConnectOptions { private func makeNodeConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults) let displayName = self.resolvedDisplayName(defaults: defaults)
@ -288,6 +290,21 @@ final class GatewayConnectionController {
clientDisplayName: displayName) 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 { private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName" let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

View File

@ -19,6 +19,7 @@ final class NodeAppModel {
let camera = CameraController() let camera = CameraController()
private let screenRecorder = ScreenRecordService() private let screenRecorder = ScreenRecordService()
var gatewayStatusText: String = "Offline" var gatewayStatusText: String = "Offline"
var operatorGatewayStatusText: String = "Offline"
var gatewayServerName: String? var gatewayServerName: String?
var gatewayRemoteAddress: String? var gatewayRemoteAddress: String?
var connectedGatewayID: String? var connectedGatewayID: String?
@ -26,7 +27,9 @@ final class NodeAppModel {
var mainSessionKey: String = "main" var mainSessionKey: String = "main"
private let gateway = GatewayNodeSession() private let gateway = GatewayNodeSession()
private let operatorGateway = GatewayNodeSession()
private var gatewayTask: Task<Void, Never>? private var gatewayTask: Task<Void, Never>?
private var operatorGatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>? private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
@ -36,6 +39,7 @@ final class NodeAppModel {
private var gatewayConnected = false private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway } var gatewaySession: GatewayNodeSession { self.gateway }
var operatorGatewaySession: GatewayNodeSession { self.operatorGateway }
var cameraHUDText: String? var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind? var cameraHUDKind: CameraHUDKind?
@ -55,7 +59,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(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") let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled) self.talkMode.setEnabled(talkEnabled)
@ -209,9 +213,13 @@ final class NodeAppModel {
tls: GatewayTLSParams?, tls: GatewayTLSParams?,
token: String?, token: String?,
password: String?, password: String?,
connectOptions: GatewayConnectOptions) nodeConnectOptions: GatewayConnectOptions,
operatorConnectOptions: GatewayConnectOptions)
{ {
self.gatewayTask?.cancel() self.gatewayTask?.cancel()
self.gatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.gatewayServerName = nil self.gatewayServerName = nil
self.gatewayRemoteAddress = nil self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
@ -219,7 +227,8 @@ final class NodeAppModel {
self.gatewayConnected = false self.gatewayConnected = false
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil 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 { self.gatewayTask = Task {
var attempt = 0 var attempt = 0
@ -239,8 +248,8 @@ final class NodeAppModel {
url: url, url: url,
token: token, token: token,
password: password, password: password,
connectOptions: connectOptions, connectOptions: nodeConnectOptions,
sessionBox: sessionBox, sessionBox: nodeSessionBox,
onConnected: { [weak self] in onConnected: { [weak self] in
guard let self else { return } guard let self else { return }
await MainActor.run { await MainActor.run {
@ -299,6 +308,7 @@ final class NodeAppModel {
await MainActor.run { await MainActor.run {
self.gatewayStatusText = "Offline" self.gatewayStatusText = "Offline"
self.operatorGatewayStatusText = "Offline"
self.gatewayServerName = nil self.gatewayServerName = nil
self.gatewayRemoteAddress = nil self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil self.connectedGatewayID = nil
@ -311,15 +321,75 @@ final class NodeAppModel {
self.showLocalCanvasOnDisconnect() 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() { func disconnectGateway() {
self.gatewayTask?.cancel() self.gatewayTask?.cancel()
self.gatewayTask = nil self.gatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil self.voiceWakeSyncTask = nil
Task { await self.gateway.disconnect() } Task { await self.gateway.disconnect() }
Task { await self.operatorGateway.disconnect() }
self.gatewayStatusText = "Offline" self.gatewayStatusText = "Offline"
self.operatorGatewayStatusText = "Offline"
self.gatewayServerName = nil self.gatewayServerName = nil
self.gatewayRemoteAddress = nil self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil self.connectedGatewayID = nil

View File

@ -52,7 +52,7 @@ struct RootCanvas: View {
SettingsTab() SettingsTab()
case .chat: case .chat:
ChatSheet( ChatSheet(
gateway: self.appModel.gatewaySession, gateway: self.appModel.operatorGatewaySession,
sessionKey: self.appModel.mainSessionKey, sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor) userAccent: self.appModel.seamColor)
} }

View File

@ -67,6 +67,7 @@ struct SettingsTab: View {
Section("Gateway") { Section("Gateway") {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText) LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Operator Status", value: self.appModel.operatorGatewayStatusText)
if let serverName = self.appModel.gatewayServerName { if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName) LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress { if let addr = self.appModel.gatewayRemoteAddress {

View File

@ -44,14 +44,16 @@ final class TalkModeManager: NSObject {
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
private var gateway: GatewayNodeSession? private var gateway: GatewayNodeSession?
private var supportsChatSubscribe: Bool = true
private let silenceWindow: TimeInterval = 0.7 private let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode") private let logger = Logger(subsystem: "com.clawdbot", category: "TalkMode")
func attachGateway(_ gateway: GatewayNodeSession) { func attachGateway(_ gateway: GatewayNodeSession, supportsChatSubscribe: Bool = true) {
self.gateway = gateway self.gateway = gateway
self.supportsChatSubscribe = supportsChatSubscribe
} }
func updateMainSessionKey(_ sessionKey: String?) { func updateMainSessionKey(_ sessionKey: String?) {
@ -296,6 +298,7 @@ final class TalkModeManager: NSObject {
} }
private func subscribeChatIfNeeded(sessionKey: String) async { private func subscribeChatIfNeeded(sessionKey: String) async {
guard self.supportsChatSubscribe else { return }
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return } guard !key.isEmpty else { return }
guard let gateway else { return } guard let gateway else { return }
@ -308,6 +311,7 @@ final class TalkModeManager: NSObject {
} }
private func unsubscribeAllChats() async { private func unsubscribeAllChats() async {
guard self.supportsChatSubscribe else { return }
guard let gateway else { return } guard let gateway else { return }
let keys = self.chatSubscribedSessionKeys let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll() self.chatSubscribedSessionKeys.removeAll()