From 6e33f3f0f3e65df005bf77f1bf9ca4974390079e Mon Sep 17 00:00:00 2001 From: Chris Herold Date: Tue, 27 Jan 2026 09:59:47 -0800 Subject: [PATCH] iOS: implement dual-connection gateway architecture with deadlock fix Aligns the iOS app with the Clawnet refactor by implementing proper role separation for gateway connections. Uses separate operator and node sessions to match the gateway's authorization requirements. Changes: - New GatewayOperatorSession: Wraps GatewayChannelActor for operator-role RPC requests (chat.*, health, sessions.list) without invoke handling - Dual-connection architecture: Operator session for requests, node session for node.event calls (e.g., chat.subscribe) - Separate websocket sessions: Each connection gets its own URLSession to prevent response cross-talk - Updated chat transport: IOSGatewayChatTransport uses operator session for requests, node session for subscriptions ClawdbotKit (shared): - Deadlock fix in GatewayChannel.swift: Moved connection finalization (listen(), connected=true, isConnecting=false, waiter resumption) to occur before calling pushHandler. This fixes a latent bug where requests made from onConnected callbacks would deadlock. Does not affect macOS (its callback doesn't make requests). - Package.swift: Fixed argument order for Swift 6.2 compatibility iOS chat is now working. This is the base PR to unlock further work on the iOS app. --- apps/ios/Sources/Chat/ChatSheet.swift | 9 +- .../Chat/IOSGatewayChatTransport.swift | 10 +- .../Gateway/GatewayConnectionController.swift | 41 +- .../Gateway/GatewayDiscoveryModel.swift | 190 ++++++- .../Gateway/GatewayOperatorSession.swift | 163 ++++++ apps/ios/Sources/Model/NodeAppModel.swift | 483 ++++++++++++++---- apps/ios/Sources/RootCanvas.swift | 34 +- apps/ios/Sources/RootTabs.swift | 37 +- apps/ios/Sources/Settings/SettingsTab.swift | 33 +- apps/ios/Sources/Status/StatusPill.swift | 52 +- apps/ios/Sources/Voice/TalkModeManager.swift | 26 +- .../GatewayConnectionControllerTests.swift | 35 ++ .../Tests/GatewayDualSessionStateTests.swift | 52 ++ apps/ios/Tests/GatewayPairingStateTests.swift | 146 ++++++ .../Tests/IOSGatewayChatTransportTests.swift | 5 +- apps/ios/Tests/SwiftUIRenderSmokeTests.swift | 6 +- .../Tests/TestGatewayWebSocketSession.swift | 220 ++++++++ .../Sources/MoltbotChatUI/ChatViewModel.swift | 5 +- .../Sources/MoltbotKit/GatewayChannel.swift | 58 +-- .../MoltbotKit/GatewayNodeSession.swift | 2 +- docs/platforms/ios.md | 42 +- 21 files changed, 1438 insertions(+), 211 deletions(-) create mode 100644 apps/ios/Sources/Gateway/GatewayOperatorSession.swift create mode 100644 apps/ios/Tests/GatewayDualSessionStateTests.swift create mode 100644 apps/ios/Tests/GatewayPairingStateTests.swift create mode 100644 apps/ios/Tests/TestGatewayWebSocketSession.swift diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index 978060f68..f6318ea8e 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -7,8 +7,13 @@ struct ChatSheet: View { @State private var viewModel: MoltbotChatViewModel private let userAccent: Color? - init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { - let transport = IOSGatewayChatTransport(gateway: gateway) + init( + gateway: GatewayOperatorSession, + nodeSession: GatewayNodeSession, + sessionKey: String, + userAccent: Color? = nil + ) { + let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession) self._viewModel = State( initialValue: MoltbotChatViewModel( sessionKey: sessionKey, diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index ed1e1bcca..0c85790ca 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -4,10 +4,13 @@ import MoltbotProtocol import Foundation struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable { - private let gateway: GatewayNodeSession + private let gateway: GatewayOperatorSession + /// Node session is used for sending node.event (e.g., chat.subscribe). + private let nodeSession: GatewayNodeSession - init(gateway: GatewayNodeSession) { + init(gateway: GatewayOperatorSession, nodeSession: GatewayNodeSession) { self.gateway = gateway + self.nodeSession = nodeSession } func abortRun(sessionKey: String, runId: String) async throws { @@ -36,7 +39,8 @@ struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable { 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) + // Use node session for chat.subscribe (node.event). + await self.nodeSession.sendEvent(event: "chat.subscribe", payloadJSON: json) } func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload { diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 67dbecc21..8fd31c62d 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 operatorConnectOptions = self.makeOperatorConnectOptions() + let nodeConnectOptions = self.makeNodeConnectOptions() Task { [weak self] in guard let self else { return } @@ -218,7 +219,8 @@ final class GatewayConnectionController { tls: tls, token: token, password: password, - connectOptions: connectOptions) + operatorConnectOptions: operatorConnectOptions, + nodeConnectOptions: nodeConnectOptions) } } @@ -273,10 +275,28 @@ final class GatewayConnectionController { "manual|\(host.lowercased())|\(port)" } - private func makeConnectOptions() -> GatewayConnectOptions { + private func makeOperatorConnectOptions() -> GatewayConnectOptions { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) + return GatewayConnectOptions( + role: "operator", + scopes: self.currentScopes(), + caps: [], + commands: [], + permissions: [:], + clientId: "moltbot-ios", + clientMode: "ui", + clientDisplayName: displayName) + } + + private func makeNodeConnectOptions() -> GatewayConnectOptions { + let defaults = UserDefaults.standard + let displayName = self.resolvedDisplayName(defaults: defaults) + + // Node role should not request operator scopes. The gateway treats scopes + // as operator-only permissions and each role has its own device token, so + // node connections should have empty scopes. return GatewayConnectOptions( role: "node", scopes: [], @@ -288,6 +308,12 @@ final class GatewayConnectionController { clientDisplayName: displayName) } + private func currentScopes() -> [String] { + // operator.read/write for chat operations. + // operator.pairing to receive device.pair.* events for pairing UI. + ["operator.read", "operator.write", "operator.pairing"] + } + private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -324,6 +350,10 @@ final class GatewayConnectionController { } private func currentCommands() -> [String] { + // Only declare commands that match the gateway iOS allowlist. + // See: src/gateway/node-command-policy.ts - PLATFORM_DEFAULTS.ios + // iOS allowlist: CANVAS_COMMANDS + CAMERA_COMMANDS + SCREEN_COMMANDS + LOCATION_COMMANDS + // System commands (run, which, notify, execApprovals.*) are NOT on the iOS allowlist. var commands: [String] = [ MoltbotCanvasCommand.present.rawValue, MoltbotCanvasCommand.hide.rawValue, @@ -334,11 +364,6 @@ final class GatewayConnectionController { MoltbotCanvasA2UICommand.pushJSONL.rawValue, MoltbotCanvasA2UICommand.reset.rawValue, MoltbotScreenCommand.record.rawValue, - MoltbotSystemCommand.notify.rawValue, - MoltbotSystemCommand.which.rawValue, - MoltbotSystemCommand.run.rawValue, - MoltbotSystemCommand.execApprovalsGet.rawValue, - MoltbotSystemCommand.execApprovalsSet.rawValue, ] let caps = Set(self.currentCaps()) diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index 19be913f4..c90d1e644 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -33,6 +33,9 @@ final class GatewayDiscoveryModel { private var browsers: [String: NWBrowser] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] + private var resultsByDomain: [String: Set] = [:] + private var resolvedTXTByID: [String: [String: String]] = [:] + private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] private var statesByDomain: [String: NWBrowser.State] = [:] private var debugLoggingEnabled = false private var lastStableIDs = Set() @@ -71,34 +74,8 @@ final class GatewayDiscoveryModel { browser.browseResultsChangedHandler = { [weak self] results, _ in Task { @MainActor in guard let self else { return } - self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in - switch result.endpoint { - case let .service(name, _, _, _): - let decodedName = BonjourEscapes.decode(name) - let txt = result.endpoint.txtRecord?.dictionary ?? [:] - let advertisedName = txt["displayName"] - let prettyAdvertised = advertisedName - .map(Self.prettifyInstanceName) - .flatMap { $0.isEmpty ? nil : $0 } - let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) - return DiscoveredGateway( - name: prettyName, - endpoint: result.endpoint, - stableID: GatewayEndpointID.stableID(result.endpoint), - debugID: GatewayEndpointID.prettyDescription(result.endpoint), - lanHost: Self.txtValue(txt, key: "lanHost"), - tailnetDns: Self.txtValue(txt, key: "tailnetDns"), - gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), - canvasPort: Self.txtIntValue(txt, key: "canvasPort"), - tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), - tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), - cliPath: Self.txtValue(txt, key: "cliPath")) - default: - return nil - } - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - + self.resultsByDomain[domain] = results + self.updateGatewaysForDomain(domain: domain, results: results) self.recomputeGateways() } } @@ -115,11 +92,106 @@ final class GatewayDiscoveryModel { } self.browsers = [:] self.gatewaysByDomain = [:] + self.resultsByDomain = [:] + self.resolvedTXTByID = [:] + self.pendingTXTResolvers.values.forEach { $0.cancel() } + self.pendingTXTResolvers = [:] self.statesByDomain = [:] self.gateways = [] self.statusText = "Stopped" } + private func updateGatewaysForAllDomains() { + for (domain, results) in self.resultsByDomain { + self.updateGatewaysForDomain(domain: domain, results: results) + } + } + + private func updateGatewaysForDomain(domain: String, results: Set) { + self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in + switch result.endpoint { + case let .service(name, type, domainName, _): + let decodedName = BonjourEscapes.decode(name) + let stableID = GatewayEndpointID.stableID(result.endpoint) + let txt = self.mergedTXT(for: result, stableID: stableID) + let advertisedName = txt["displayName"] + let prettyAdvertised = advertisedName + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) + let lanHost = Self.txtValue(txt, key: "lanHost") + let tailnetDns = Self.txtValue(txt, key: "tailnetDns") + if lanHost == nil && tailnetDns == nil { + self.ensureTXTResolution( + stableID: stableID, + serviceName: name, + type: type, + domain: domainName) + } + return DiscoveredGateway( + name: prettyName, + endpoint: result.endpoint, + stableID: stableID, + debugID: GatewayEndpointID.prettyDescription(result.endpoint), + lanHost: lanHost, + tailnetDns: tailnetDns, + gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"), + canvasPort: Self.txtIntValue(txt, key: "canvasPort"), + tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"), + tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"), + cliPath: Self.txtValue(txt, key: "cliPath")) + default: + return nil + } + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func mergedTXT(for result: NWBrowser.Result, stableID: String) -> [String: String] { + var merged = self.resolvedTXTByID[stableID] ?? [:] + if case let .bonjour(txt) = result.metadata { + merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new }) + } + if let endpointTxt = result.endpoint.txtRecord?.dictionary { + merged.merge(endpointTxt, uniquingKeysWith: { _, new in new }) + } + return merged + } + + private func ensureTXTResolution( + stableID: String, + serviceName: String, + type: String, + domain: String) + { + guard self.resolvedTXTByID[stableID] == nil else { return } + guard self.pendingTXTResolvers[stableID] == nil else { return } + + let resolver = GatewayTXTResolver( + name: serviceName, + type: type, + domain: domain) + { [weak self] result in + Task { @MainActor in + guard let self else { return } + self.pendingTXTResolvers[stableID] = nil + switch result { + case let .success(txt): + guard !txt.isEmpty else { return } + self.resolvedTXTByID[stableID] = txt + self.appendDebugLog("resolved TXT for \(serviceName)") + self.updateGatewaysForAllDomains() + self.recomputeGateways() + case .failure: + break + } + } + } + + self.pendingTXTResolvers[stableID] = resolver + resolver.start() + } + private func recomputeGateways() { let next = self.gatewaysByDomain.values .flatMap(\.self) @@ -222,3 +294,65 @@ final class GatewayDiscoveryModel { return raw == "1" || raw == "true" || raw == "yes" } } + +final class GatewayTXTResolver: NSObject, NetServiceDelegate { + private let service: NetService + private let completion: (Result<[String: String], Error>) -> Void + private var didFinish = false + + init( + name: String, + type: String, + domain: String, + completion: @escaping (Result<[String: String], Error>) -> Void) + { + self.service = NetService(domain: domain, type: type, name: name) + self.completion = completion + super.init() + self.service.delegate = self + } + + func start(timeout: TimeInterval = 2.0) { + self.service.schedule(in: .main, forMode: .common) + self.service.resolve(withTimeout: timeout) + } + + func cancel() { + self.finish(result: .failure(GatewayTXTResolverError.cancelled)) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + let txt = Self.decodeTXT(sender.txtRecordData()) + self.finish(result: .success(txt)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict))) + } + + private func finish(result: Result<[String: String], Error>) { + guard !self.didFinish else { return } + self.didFinish = true + self.service.stop() + self.service.remove(from: .main, forMode: .common) + self.completion(result) + } + + private static func decodeTXT(_ data: Data?) -> [String: String] { + guard let data else { return [:] } + let dict = NetService.dictionary(fromTXTRecord: data) + var out: [String: String] = [:] + out.reserveCapacity(dict.count) + for (key, value) in dict { + if let str = String(data: value, encoding: .utf8) { + out[key] = str + } + } + return out + } +} + +enum GatewayTXTResolverError: Error { + case cancelled + case resolveFailed([String: NSNumber]) +} diff --git a/apps/ios/Sources/Gateway/GatewayOperatorSession.swift b/apps/ios/Sources/Gateway/GatewayOperatorSession.swift new file mode 100644 index 000000000..170cc3a77 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayOperatorSession.swift @@ -0,0 +1,163 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation + +/// Operator-role gateway session for iOS. +/// +/// Wraps `GatewayChannelActor` to provide a similar interface to `GatewayNodeSession` +/// but without invoke handling (operator role does not receive node.invoke requests). +public actor GatewayOperatorSession { + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private var channel: GatewayChannelActor? + private var activeURL: URL? + private var activeToken: String? + private var activePassword: String? + private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] + + public init() {} + + public func connect( + url: URL, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?, + onConnected: @escaping @Sendable () async -> Void, + onDisconnected: @escaping @Sendable (String) async -> Void + ) async throws { + let shouldReconnect = self.activeURL != url || + self.activeToken != token || + self.activePassword != password || + self.channel == nil + + if shouldReconnect { + if let existing = self.channel { + await existing.shutdown() + } + let channel = GatewayChannelActor( + url: url, + token: token, + password: password, + session: sessionBox, + pushHandler: { [weak self] push in + await self?.handlePush(push, onConnected: onConnected) + }, + connectOptions: connectOptions, + disconnectHandler: { [weak self] reason in + guard self != nil else { return } + await onDisconnected(reason) + }) + self.channel = channel + self.activeURL = url + self.activeToken = token + self.activePassword = password + } + + guard let channel = self.channel else { + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "gateway channel unavailable", + ]) + } + + do { + try await channel.connect() + // onConnected is called via pushHandler when snapshot arrives + } catch { + await onDisconnected(error.localizedDescription) + throw error + } + } + + public func disconnect() async { + await self.channel?.shutdown() + self.channel = nil + self.activeURL = nil + self.activeToken = nil + self.activePassword = nil + } + + public func currentRemoteAddress() -> String? { + guard let url = self.activeURL else { return nil } + guard let host = url.host else { return url.absoluteString } + let port = url.port ?? (url.scheme == "wss" ? 443 : 80) + if host.contains(":") { + return "[\(host)]:\(port)" + } + return "\(host):\(port)" + } + + public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data { + guard let channel = self.channel else { + throw NSError(domain: "Gateway", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "not connected", + ]) + } + + let params = try self.decodeParamsJSON(paramsJSON) + return try await channel.request( + method: method, + params: params, + timeoutMs: Double(timeoutSeconds * 1000)) + } + + public func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream { + let id = UUID() + let session = self + return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in + self.serverEventSubscribers[id] = continuation + continuation.onTermination = { @Sendable _ in + Task { await session.removeServerEventSubscriber(id) } + } + } + } + + private func handlePush( + _ push: GatewayPush, + onConnected: @escaping @Sendable () async -> Void + ) async { + switch push { + case .snapshot: + await onConnected() + case let .event(evt): + self.broadcastServerEvent(evt) + case let .seqGap(expected, received): + // Broadcast a synthetic event so subscribers can handle gaps + let gapEvent = EventFrame( + type: "evt", + event: "seqGap", + payload: MoltbotProtocol.AnyCodable(["expected": expected, "received": received]), + seq: received, + stateversion: nil) + self.broadcastServerEvent(gapEvent) + } + } + + private func broadcastServerEvent(_ evt: EventFrame) { + for (id, continuation) in self.serverEventSubscribers { + if case .terminated = continuation.yield(evt) { + self.serverEventSubscribers.removeValue(forKey: id) + } + } + } + + private func removeServerEventSubscriber(_ id: UUID) { + self.serverEventSubscribers.removeValue(forKey: id) + } + + private func decodeParamsJSON(_ paramsJSON: String?) throws -> [String: MoltbotKit.AnyCodable]? { + guard let paramsJSON, !paramsJSON.isEmpty else { return nil } + guard let data = paramsJSON.data(using: .utf8) else { + throw NSError(domain: "Gateway", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "paramsJSON not UTF-8", + ]) + } + let raw = try JSONSerialization.jsonObject(with: data) + guard let dict = raw as? [String: Any] else { + return nil + } + return dict.reduce(into: [:]) { acc, entry in + acc[entry.key] = MoltbotKit.AnyCodable(entry.value) + } + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 2e19a17eb..f2b151be5 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1,4 +1,5 @@ import MoltbotKit +import MoltbotProtocol import Network import Observation import SwiftUI @@ -14,6 +15,18 @@ final class NodeAppModel { case error } + enum ConnectionRole: String { + case `operator` + case node + } + + enum GatewayPairingState: Equatable { + case none + case operatorPending + case nodePending + case bothPending + } + var isBackgrounded: Bool = false let screen = ScreenController() let camera = CameraController() @@ -23,26 +36,52 @@ final class NodeAppModel { var gatewayRemoteAddress: String? var connectedGatewayID: String? var seamColorHex: String? - var mainSessionKey: String = "main" + var mainSessionKey: String = "agent:main:main" + var operatorPairingPending: Bool = false + var nodePairingPending: Bool = false - private let gateway = GatewayNodeSession() - private var gatewayTask: Task? + var gatewayPairingState: GatewayPairingState { + switch (self.operatorPairingPending, self.nodePairingPending) { + case (true, true): + return .bothPending + case (true, false): + return .operatorPending + case (false, true): + return .nodePending + case (false, false): + return .none + } + } + + private let gateway: GatewayOperatorSession + private let nodeSession: GatewayNodeSession + private var operatorTask: Task? + private var nodeTask: Task? private var voiceWakeSyncTask: Task? + private var pairingEventTask: Task? @ObservationIgnored private var cameraHUDDismissTask: Task? let voiceWake = VoiceWakeManager() let talkMode = TalkModeManager() private let locationService = LocationService() private var lastAutoA2uiURL: String? - private var gatewayConnected = false - var gatewaySession: GatewayNodeSession { self.gateway } + var operatorConnected: Bool = false + var nodeConnected: Bool = false + var gatewaySession: GatewayOperatorSession { self.gateway } + var gatewayNodeSession: GatewayNodeSession { self.nodeSession } var cameraHUDText: String? var cameraHUDKind: CameraHUDKind? var cameraFlashNonce: Int = 0 var screenRecordActive: Bool = false - init() { + init( + gatewaySession: GatewayOperatorSession = GatewayOperatorSession(), + nodeSession: GatewayNodeSession = GatewayNodeSession()) + { + self.gateway = gatewaySession + self.nodeSession = nodeSession + self.voiceWake.configure { [weak self] cmd in guard let self else { return } let sessionKey = await MainActor.run { self.mainSessionKey } @@ -55,7 +94,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.gateway, nodeSession: self.nodeSession) let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") self.talkMode.setEnabled(talkEnabled) @@ -151,7 +190,7 @@ final class NodeAppModel { } private func resolveA2UIHostURL() async -> String? { - guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil } + guard let raw = await self.nodeSession.currentCanvasHostUrl() else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=ios" @@ -209,121 +248,349 @@ final class NodeAppModel { tls: GatewayTLSParams?, token: String?, password: String?, - connectOptions: GatewayConnectOptions) + operatorConnectOptions: GatewayConnectOptions, + nodeConnectOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox? = nil) { - self.gatewayTask?.cancel() + // Cancel any existing connection tasks + self.operatorTask?.cancel() + self.nodeTask?.cancel() + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil + self.pairingEventTask?.cancel() + self.pairingEventTask = nil + + // Reset state self.gatewayServerName = nil self.gatewayRemoteAddress = nil let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) self.connectedGatewayID = id.isEmpty ? url.absoluteString : id - self.gatewayConnected = false - self.voiceWakeSyncTask?.cancel() - self.voiceWakeSyncTask = nil - let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } + self.operatorConnected = false + self.nodeConnected = false + self.clearPairingPending() + self.gatewayStatusText = "Connecting…" - self.gatewayTask = Task { - var attempt = 0 - while !Task.isCancelled { - await MainActor.run { - if attempt == 0 { - self.gatewayStatusText = "Connecting…" - } else { - self.gatewayStatusText = "Reconnecting…" - } - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - } + // Create separate session boxes for operator and node to avoid shared websocket state. + // Each connection needs its own URLSession/TLS session to prevent response cross-talk. + func makeSessionBox() -> WebSocketSessionBox? { + if let sessionBox { return sessionBox } + if let tls { return WebSocketSessionBox(session: GatewayTLSPinningSession(params: tls)) } + return nil + } - do { - try await self.gateway.connect( - url: url, - token: token, - password: password, - connectOptions: connectOptions, - sessionBox: sessionBox, - onConnected: { [weak self] in - guard let self else { return } + // Start independent connection loops for operator and node + self.operatorTask = Task { [weak self] in + await self?.operatorConnectLoop( + url: url, + token: token, + password: password, + connectOptions: operatorConnectOptions, + sessionBox: makeSessionBox()) + } + + self.nodeTask = Task { [weak self] in + await self?.nodeConnectLoop( + url: url, + token: token, + password: password, + connectOptions: nodeConnectOptions, + sessionBox: makeSessionBox()) + } + } + + /// Independent connection loop for the operator session. + private func operatorConnectLoop( + url: URL, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?) + async { + var attempt = 0 + + while !Task.isCancelled { + do { + try await self.gateway.connect( + url: url, + token: token, + password: password, + connectOptions: connectOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + await MainActor.run { + self.setPairingPending(for: .operator, pending: false) + self.operatorConnected = true + self.gatewayServerName = url.host ?? "gateway" + self.updateGatewayConnectionStatus() + } + if let addr = await self.gateway.currentRemoteAddress() { await MainActor.run { - self.gatewayStatusText = "Connected" - self.gatewayServerName = url.host ?? "gateway" - self.gatewayConnected = true + self.gatewayRemoteAddress = addr } - if let addr = await self.gateway.currentRemoteAddress() { - await MainActor.run { - self.gatewayRemoteAddress = addr - } - } - await self.refreshBrandingFromGateway() - await self.startVoiceWakeSync() - await self.showA2UIOnConnectIfNeeded() - }, - onDisconnected: { [weak self] reason in - guard let self else { return } - await MainActor.run { - self.gatewayStatusText = "Disconnected" - self.gatewayRemoteAddress = nil - self.gatewayConnected = false + } + await self.refreshBrandingFromGateway() + await self.startVoiceWakeSync() + await self.startPairingEventSync() + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await MainActor.run { + self.operatorConnected = false + self.gatewayRemoteAddress = nil + if !self.nodeConnected { + self.gatewayServerName = nil self.showLocalCanvasOnDisconnect() - self.gatewayStatusText = "Disconnected: \(reason)" } - }, - onInvoke: { [weak self] req in - guard let self else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: MoltbotNodeError( - code: .unavailable, - message: "UNAVAILABLE: node not ready")) - } - return await self.handleInvoke(req) - }) + self.updateGatewayConnectionStatus(reason: reason) + self.updatePairingPending(for: .operator, reason: reason) + } + }) - 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.gatewayStatusText = "Gateway error: \(error.localizedDescription)" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.gatewayConnected = false - self.showLocalCanvasOnDisconnect() - } - let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) - try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + // Connection succeeded - reset attempt counter and wait before checking again + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + } catch { + if Task.isCancelled { break } + attempt += 1 + await MainActor.run { + self.updatePairingPending(for: .operator, reason: error.localizedDescription) + self.operatorConnected = false + self.updateGatewayConnectionStatus(reason: error.localizedDescription) } + + // Exponential backoff + 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.gatewayStatusText = "Offline" - self.gatewayServerName = nil - self.gatewayRemoteAddress = nil - self.connectedGatewayID = nil - self.gatewayConnected = false - self.seamColorHex = nil - if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { - self.mainSessionKey = "main" - self.talkMode.updateMainSessionKey(self.mainSessionKey) + // Cleanup on task cancellation + await self.gateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.setPairingPending(for: .operator, pending: false) + self.updateGatewayConnectionStatus() + } + } + + /// Independent connection loop for the node session. + private func nodeConnectLoop( + url: URL, + token: String?, + password: String?, + connectOptions: GatewayConnectOptions, + sessionBox: WebSocketSessionBox?) + async { + var attempt = 0 + + while !Task.isCancelled { + do { + try await self.nodeSession.connect( + url: url, + token: token, + password: password, + connectOptions: connectOptions, + sessionBox: sessionBox, + onConnected: { [weak self] in + guard let self else { return } + await MainActor.run { + self.setPairingPending(for: .node, pending: false) + self.nodeConnected = true + if self.gatewayServerName == nil { + self.gatewayServerName = url.host ?? "gateway" + } + self.updateGatewayConnectionStatus() + } + await self.showA2UIOnConnectIfNeeded() + }, + onDisconnected: { [weak self] reason in + guard let self else { return } + await MainActor.run { + self.nodeConnected = false + if !self.operatorConnected { + self.gatewayServerName = nil + self.showLocalCanvasOnDisconnect() + } + self.updateGatewayConnectionStatus(reason: reason) + } + }, + onInvoke: { [weak self] req in + guard let self else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: MoltbotNodeError( + code: .unavailable, + message: "UNAVAILABLE: node not ready")) + } + return await self.handleInvoke(req) + }) + + // Connection succeeded - reset attempt counter and wait before checking again + attempt = 0 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + } catch { + if Task.isCancelled { break } + attempt += 1 + await MainActor.run { + self.updatePairingPending(for: .node, reason: error.localizedDescription) + self.nodeConnected = false + self.updateGatewayConnectionStatus(reason: error.localizedDescription) } - self.showLocalCanvasOnDisconnect() + + // Exponential backoff + let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) + try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + } + } + + // Cleanup on task cancellation + await self.nodeSession.disconnect() + await MainActor.run { + self.nodeConnected = false + self.setPairingPending(for: .node, pending: false) + self.updateGatewayConnectionStatus() + } + } + + private func updateGatewayConnectionStatus(reason: String? = nil) { + let trimmedReason = (reason ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + switch (self.operatorConnected, self.nodeConnected) { + case (true, true): + self.gatewayStatusText = "Connected (operator + node)" + case (true, false): + self.gatewayStatusText = "Connected (operator only)" + case (false, true): + self.gatewayStatusText = "Connected (node only)" + case (false, false): + if trimmedReason.isEmpty { + self.gatewayStatusText = "Disconnected" + } else { + self.gatewayStatusText = "Disconnected: \(trimmedReason)" } } } + private func setPairingPending(for role: ConnectionRole, pending: Bool) { + switch role { + case .operator: + self.operatorPairingPending = pending + case .node: + self.nodePairingPending = pending + } + } + + private func clearPairingPending() { + self.operatorPairingPending = false + self.nodePairingPending = false + } + + private func updatePairingPending(for role: ConnectionRole, reason: String) { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.setPairingPending(for: role, pending: false) + return + } + let lower = trimmed.lowercased() + let pending = lower.contains("pairing") || lower.contains("approval") + self.setPairingPending(for: role, pending: pending) + } + + private func startPairingEventSync() async { + self.pairingEventTask?.cancel() + let myDeviceId = DeviceIdentityStore.loadOrCreate().deviceId + self.pairingEventTask = Task { [weak self] in + guard let self else { return } + let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200) + for await evt in stream { + if Task.isCancelled { return } + await self.handlePairingEvent(evt, myDeviceId: myDeviceId) + } + } + } + + private func handlePairingEvent(_ evt: EventFrame, myDeviceId: String) async { + // Handle device.pair.requested: set pairing pending for the role + // Handle device.pair.resolved: clear pairing pending for the role + switch evt.event { + case "device.pair.requested": + guard let payload = evt.payload else { return } + struct RequestedPayload: Decodable { + var deviceId: String + var role: String? + } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: RequestedPayload.self) else { return } + guard decoded.deviceId == myDeviceId else { return } + let role = self.parsePairingRole(decoded.role) + await MainActor.run { + self.setPairingPendingFromEvent(role: role, pending: true) + } + case "device.pair.resolved": + guard let payload = evt.payload else { return } + struct ResolvedPayload: Decodable { + var deviceId: String + var decision: String? + } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: ResolvedPayload.self) else { return } + guard decoded.deviceId == myDeviceId else { return } + // On resolution (approved or rejected), clear the pending state. + // The role isn't always in resolved events, so clear both for this device. + await MainActor.run { + self.operatorPairingPending = false + self.nodePairingPending = false + } + default: + break + } + } + + private func parsePairingRole(_ roleString: String?) -> ConnectionRole? { + guard let role = roleString?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return nil + } + switch role { + case "operator": return .operator + case "node": return .node + default: return nil + } + } + + private func setPairingPendingFromEvent(role: ConnectionRole?, pending: Bool) { + // If role is known, set just that role. Otherwise set both. + switch role { + case .operator: + self.operatorPairingPending = pending + case .node: + self.nodePairingPending = pending + case nil: + self.operatorPairingPending = pending + self.nodePairingPending = pending + } + } + func disconnectGateway() { - self.gatewayTask?.cancel() - self.gatewayTask = nil + self.operatorTask?.cancel() + self.operatorTask = nil + self.nodeTask?.cancel() + self.nodeTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - Task { await self.gateway.disconnect() } + self.pairingEventTask?.cancel() + self.pairingEventTask = nil + Task { + await self.gateway.disconnect() + await self.nodeSession.disconnect() + } self.gatewayStatusText = "Offline" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil - self.gatewayConnected = false + self.operatorConnected = false + self.nodeConnected = false + self.clearPairingPending() self.seamColorHex = nil if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main" @@ -445,7 +712,7 @@ final class NodeAppModel { NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", ]) } - await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json) + await self.nodeSession.sendEvent(event: "voice.transcript", payloadJSON: json) } func handleDeepLink(url: URL) async { @@ -494,11 +761,11 @@ final class NodeAppModel { NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", ]) } - await self.gateway.sendEvent(event: "agent.request", payloadJSON: json) + await self.nodeSession.sendEvent(event: "agent.request", payloadJSON: json) } private func isGatewayConnected() async -> Bool { - self.gatewayConnected + self.operatorConnected } private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { @@ -705,7 +972,7 @@ final class NodeAppModel { """) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) case MoltbotCanvasA2UICommand.push.rawValue, MoltbotCanvasA2UICommand.pushJSONL.rawValue: - let messages: [AnyCodable] + let messages: [MoltbotKit.AnyCodable] if command == MoltbotCanvasA2UICommand.pushJSONL.rawValue { let params = try Self.decodeParams(MoltbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) messages = try MoltbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) @@ -955,5 +1222,21 @@ extension NodeAppModel { func _test_showLocalCanvasOnDisconnect() { self.showLocalCanvasOnDisconnect() } + + func _test_setPairingPending(role: ConnectionRole, pending: Bool) { + self.setPairingPending(for: role, pending: pending) + } + + func _test_clearPairingPending() { + self.clearPairingPending() + } + + func _test_updatePairingPending(role: ConnectionRole, reason: String) { + self.updatePairingPending(for: role, reason: reason) + } + + func _test_handlePairingEvent(_ evt: EventFrame, myDeviceId: String) async { + await self.handlePairingEvent(evt, myDeviceId: myDeviceId) + } } #endif diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 93cb81627..359f7e1cd 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -53,6 +53,7 @@ struct RootCanvas: View { case .chat: ChatSheet( gateway: self.appModel.gatewaySession, + nodeSession: self.appModel.gatewayNodeSession, sessionKey: self.appModel.mainSessionKey, userAccent: self.appModel.seamColor) } @@ -91,8 +92,39 @@ struct RootCanvas: View { } } + private var pairingRole: StatusPill.PairingRole? { + switch self.appModel.gatewayPairingState { + case .none: + return nil + case .operatorPending: + return .operator + case .nodePending: + return .node + case .bothPending: + return .both + } + } + + private var connectionRole: StatusPill.ConnectionRole? { + switch (self.appModel.operatorConnected, self.appModel.nodeConnected) { + case (true, true): + return .both + case (true, false): + return .operatorOnly + case (false, true): + return .nodeOnly + case (false, false): + return nil + } + } + private var gatewayStatus: StatusPill.GatewayState { - if self.appModel.gatewayServerName != nil { return .connected } + if let pairingRole { + return .pairingPending(pairingRole) + } + if let connectionRole { + return .connected(connectionRole) + } let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index f7b3fd822..33fc7e0eb 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -64,8 +64,39 @@ struct RootTabs: View { } } + private var pairingRole: StatusPill.PairingRole? { + switch self.appModel.gatewayPairingState { + case .none: + return nil + case .operatorPending: + return .operator + case .nodePending: + return .node + case .bothPending: + return .both + } + } + + private var connectionRole: StatusPill.ConnectionRole? { + switch (self.appModel.operatorConnected, self.appModel.nodeConnected) { + case (true, true): + return .both + case (true, false): + return .operatorOnly + case (false, true): + return .nodeOnly + case (false, false): + return nil + } + } + private var gatewayStatus: StatusPill.GatewayState { - if self.appModel.gatewayServerName != nil { return .connected } + if let pairingRole { + return .pairingPending(pairingRole) + } + if let connectionRole { + return .connected(connectionRole) + } let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || @@ -90,6 +121,10 @@ struct RootTabs: View { tint: .orange) } + if self.pairingRole != nil { + return nil + } + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayLower = gatewayStatus.lowercased() if gatewayLower.contains("repair") { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 8aaf39264..0cd79a1e1 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -65,8 +65,22 @@ struct SettingsTab: View { } Section("Gateway") { - LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) + LabeledContent("Discovery (Bonjour)", value: self.gatewayController.discoveryStatusText) + Text("Discovery stays active while Moltbot is open to find other gateways.") + .font(.footnote) + .foregroundStyle(.secondary) LabeledContent("Status", value: self.appModel.gatewayStatusText) + + TextField("Gateway Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway Password", text: self.$gatewayPassword) + + Text("Required to pair with a gateway. Provide a token or password from the gateway settings.") + .font(.footnote) + .foregroundStyle(.secondary) + if let serverName = self.appModel.gatewayServerName { LabeledContent("Server", value: serverName) if let addr = self.appModel.gatewayRemoteAddress { @@ -120,7 +134,7 @@ struct SettingsTab: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() - TextField("Port", value: self.$manualGatewayPort, format: .number) + TextField("Port", value: self.$manualGatewayPort, formatter: Self.portFormatter) .keyboardType(.numberPad) Toggle("Use TLS", isOn: self.$manualGatewayTLS) @@ -158,12 +172,6 @@ struct SettingsTab: View { } Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) - - TextField("Gateway Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - SecureField("Gateway Password", text: self.$gatewayPassword) } } @@ -398,6 +406,13 @@ struct SettingsTab: View { useTLS: self.manualGatewayTLS) } + private static let portFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + formatter.usesGroupingSeparator = false + return formatter + }() + private static func primaryIPv4Address() -> String? { var addrList: UnsafeMutablePointer? guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } @@ -458,7 +473,7 @@ struct SettingsTab: View { } if lines.isEmpty { - lines.append(gateway.debugID) + lines.append("Discovery details unavailable yet.") } return lines diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index cd81c011b..e5dd4885b 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -3,16 +3,52 @@ import SwiftUI struct StatusPill: View { @Environment(\.scenePhase) private var scenePhase + enum PairingRole: Equatable { + case `operator` + case node + case both + + var title: String { + switch self { + case .operator: + "Pairing pending (operator)" + case .node: + "Pairing pending (node)" + case .both: + "Pairing pending (operator + node)" + } + } + } + + enum ConnectionRole: Equatable { + case operatorOnly + case nodeOnly + case both + + var title: String { + switch self { + case .operatorOnly: + "Connected (operator only)" + case .nodeOnly: + "Connected (node only)" + case .both: + "Connected (operator + node)" + } + } + } + enum GatewayState: Equatable { - case connected + case connected(ConnectionRole) case connecting + case pairingPending(PairingRole) case error case disconnected var title: String { switch self { - case .connected: "Connected" + case let .connected(role): role.title case .connecting: "Connecting…" + case let .pairingPending(role): role.title case .error: "Error" case .disconnected: "Offline" } @@ -22,10 +58,16 @@ struct StatusPill: View { switch self { case .connected: .green case .connecting: .yellow + case .pairingPending: .orange case .error: .red case .disconnected: .gray } } + + var isConnecting: Bool { + if case .connecting = self { return true } + return false + } } struct Activity: Equatable { @@ -49,8 +91,8 @@ struct StatusPill: View { Circle() .fill(self.gateway.color) .frame(width: 9, height: 9) - .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) - .opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) + .scaleEffect(self.gateway.isConnecting ? (self.pulse ? 1.15 : 0.85) : 1.0) + .opacity(self.gateway.isConnecting ? (self.pulse ? 1.0 : 0.6) : 1.0) Text(self.gateway.title) .font(.system(size: 13, weight: .semibold)) @@ -114,7 +156,7 @@ struct StatusPill: View { } private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { - guard gateway == .connecting, scenePhase == .active else { + guard gateway.isConnecting, scenePhase == .active else { withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } return } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index c0ae8b454..994c194ab 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -43,15 +43,17 @@ final class TalkModeManager: NSObject { var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared - private var gateway: GatewayNodeSession? + private var gateway: GatewayOperatorSession? + private var nodeSession: GatewayNodeSession? private let silenceWindow: TimeInterval = 0.7 private var chatSubscribedSessionKeys = Set() private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") - func attachGateway(_ gateway: GatewayNodeSession) { + func attachGateway(_ gateway: GatewayOperatorSession, nodeSession: GatewayNodeSession? = nil) { self.gateway = gateway + self.nodeSession = nodeSession } func updateMainSessionKey(_ sessionKey: String?) { @@ -298,22 +300,23 @@ 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 } + // Use nodeSession for node.event (chat.subscribe). + guard let nodeSession else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return } let payload = "{\"sessionKey\":\"\(key)\"}" - await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload) + await nodeSession.sendEvent(event: "chat.subscribe", payloadJSON: payload) self.chatSubscribedSessionKeys.insert(key) self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") } private func unsubscribeAllChats() async { - guard let gateway else { return } + guard let nodeSession 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) + await nodeSession.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) } } @@ -339,7 +342,7 @@ final class TalkModeManager: NSObject { } } - private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String { + private func sendChat(_ message: String, gateway: GatewayOperatorSession) async throws -> String { struct SendResponse: Decodable { let runId: String } let payload: [String: Any] = [ "sessionKey": self.mainSessionKey, @@ -362,7 +365,7 @@ final class TalkModeManager: NSObject { private func waitForChatCompletion( runId: String, - gateway: GatewayNodeSession, + gateway: GatewayOperatorSession, timeoutSeconds: Int = 120) async -> ChatCompletionState { let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) @@ -397,7 +400,7 @@ final class TalkModeManager: NSObject { } private func waitForAssistantText( - gateway: GatewayNodeSession, + gateway: GatewayOperatorSession, since: Double, timeoutSeconds: Int) async throws -> String? { @@ -411,7 +414,10 @@ final class TalkModeManager: NSObject { return nil } - private func fetchLatestAssistantText(gateway: GatewayNodeSession, since: Double? = nil) async throws -> String? { + private func fetchLatestAssistantText( + gateway: GatewayOperatorSession, + since: Double? = nil) async throws -> String? + { let res = try await gateway.request( method: "chat.history", paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index c9b324ecd..2a6bf3ed2 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -76,4 +76,39 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(commands.contains(MoltbotLocationCommand.get.rawValue)) } } + + @Test @MainActor func currentCommandsMatchGatewayIOSAllowlist() { + // Verify only iOS-allowlisted commands are declared. + // iOS allowlist per node-command-policy.ts: canvas, camera, screen, location + // System commands (run, which, notify, execApprovals.*) are NOT allowed on iOS. + withUserDefaults([ + "node.instanceId": "ios-test", + "camera.enabled": true, + "location.enabledMode": MoltbotLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + #expect(commands.contains(MoltbotCanvasCommand.present.rawValue)) + #expect(commands.contains(MoltbotCanvasCommand.hide.rawValue)) + #expect(commands.contains(MoltbotCanvasCommand.navigate.rawValue)) + #expect(commands.contains(MoltbotCanvasCommand.evalJS.rawValue)) + #expect(commands.contains(MoltbotCanvasCommand.snapshot.rawValue)) + #expect(commands.contains(MoltbotCanvasA2UICommand.push.rawValue)) + #expect(commands.contains(MoltbotCanvasA2UICommand.pushJSONL.rawValue)) + #expect(commands.contains(MoltbotCanvasA2UICommand.reset.rawValue)) + #expect(commands.contains(MoltbotScreenCommand.record.rawValue)) + #expect(commands.contains(MoltbotCameraCommand.list.rawValue)) + #expect(commands.contains(MoltbotCameraCommand.snap.rawValue)) + #expect(commands.contains(MoltbotCameraCommand.clip.rawValue)) + #expect(commands.contains(MoltbotLocationCommand.get.rawValue)) + + #expect(!commands.contains(MoltbotSystemCommand.notify.rawValue)) + #expect(!commands.contains(MoltbotSystemCommand.which.rawValue)) + #expect(!commands.contains(MoltbotSystemCommand.run.rawValue)) + #expect(!commands.contains(MoltbotSystemCommand.execApprovalsGet.rawValue)) + #expect(!commands.contains(MoltbotSystemCommand.execApprovalsSet.rawValue)) + } + } } diff --git a/apps/ios/Tests/GatewayDualSessionStateTests.swift b/apps/ios/Tests/GatewayDualSessionStateTests.swift new file mode 100644 index 000000000..290947201 --- /dev/null +++ b/apps/ios/Tests/GatewayDualSessionStateTests.swift @@ -0,0 +1,52 @@ +import MoltbotKit +import Foundation +import Testing +@testable import Moltbot + +@Suite(.serialized) struct GatewayDualSessionStateTests { + @Test @MainActor func connectToGatewayStartsOperatorAndNodeSessions() async { + let session = TestGatewayWebSocketSession() + let appModel = NodeAppModel( + gatewaySession: GatewayOperatorSession(), + nodeSession: GatewayNodeSession()) + + let operatorOptions = GatewayConnectOptions( + role: "operator", + scopes: ["operator.read", "operator.write"], + caps: [], + commands: [], + permissions: [:], + clientId: "moltbot-ios", + clientMode: "ui", + clientDisplayName: "Test") + // Node role should have empty scopes - operator scopes are operator-only + let nodeOptions = GatewayConnectOptions( + role: "node", + scopes: [], + caps: ["camera"], + commands: [], + permissions: [:], + clientId: "moltbot-ios", + clientMode: "node", + clientDisplayName: "Test") + + appModel.connectToGateway( + url: URL(string: "ws://example.invalid")!, + gatewayStableID: "test", + tls: nil, + token: nil, + password: nil, + operatorConnectOptions: operatorOptions, + nodeConnectOptions: nodeOptions, + sessionBox: WebSocketSessionBox(session: session)) + + try? await Task.sleep(nanoseconds: 200_000_000) + + let roles = Set(session.snapshotConnectRoles()) + #expect(roles == ["operator", "node"]) + #expect(appModel.gatewayStatusText == "Connected (operator + node)") + + appModel.disconnectGateway() + #expect(appModel.gatewayStatusText == "Offline") + } +} diff --git a/apps/ios/Tests/GatewayPairingStateTests.swift b/apps/ios/Tests/GatewayPairingStateTests.swift new file mode 100644 index 000000000..81aeda605 --- /dev/null +++ b/apps/ios/Tests/GatewayPairingStateTests.swift @@ -0,0 +1,146 @@ +import MoltbotKit +import MoltbotProtocol +import Foundation +import Testing +@testable import Moltbot + +@Suite(.serialized) struct GatewayPairingStateTests { + @Test @MainActor func pairingPendingTransitionsAcrossRoles() { + let appModel = NodeAppModel() + + #expect(appModel.gatewayPairingState == .none) + + appModel._test_updatePairingPending(role: .operator, reason: "Pairing required") + #expect(appModel.gatewayPairingState == .operatorPending) + + appModel._test_updatePairingPending(role: .node, reason: "Awaiting approval") + #expect(appModel.gatewayPairingState == .bothPending) + + appModel._test_updatePairingPending(role: .operator, reason: "Disconnected") + #expect(appModel.gatewayPairingState == .nodePending) + + appModel._test_clearPairingPending() + #expect(appModel.gatewayPairingState == .none) + } + + @Test @MainActor func pairingPendingClearsOnEmptyReason() { + let appModel = NodeAppModel() + appModel._test_setPairingPending(role: .node, pending: true) + #expect(appModel.gatewayPairingState == .nodePending) + + appModel._test_updatePairingPending(role: .node, reason: " ") + #expect(appModel.gatewayPairingState == .none) + } + + @Test @MainActor func pairingRequestedEventSetsPendingForOperatorRole() async { + let appModel = NodeAppModel() + let myDeviceId = "test-device-123" + + let payload: [String: MoltbotProtocol.AnyCodable] = [ + "deviceId": MoltbotProtocol.AnyCodable(myDeviceId), + "role": MoltbotProtocol.AnyCodable("operator"), + ] + let evt = EventFrame( + type: "event", + event: "device.pair.requested", + payload: MoltbotProtocol.AnyCodable(payload), + seq: nil, + stateversion: nil) + + await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId) + + #expect(appModel.gatewayPairingState == .operatorPending) + } + + @Test @MainActor func pairingRequestedEventSetsPendingForNodeRole() async { + let appModel = NodeAppModel() + let myDeviceId = "test-device-456" + + let payload: [String: MoltbotProtocol.AnyCodable] = [ + "deviceId": MoltbotProtocol.AnyCodable(myDeviceId), + "role": MoltbotProtocol.AnyCodable("node"), + ] + let evt = EventFrame( + type: "event", + event: "device.pair.requested", + payload: MoltbotProtocol.AnyCodable(payload), + seq: nil, + stateversion: nil) + + await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId) + + #expect(appModel.gatewayPairingState == .nodePending) + } + + @Test @MainActor func pairingRequestedEventIgnoresOtherDevices() async { + let appModel = NodeAppModel() + let myDeviceId = "test-device-mine" + + let payload: [String: MoltbotProtocol.AnyCodable] = [ + "deviceId": MoltbotProtocol.AnyCodable("different-device"), + "role": MoltbotProtocol.AnyCodable("operator"), + ] + let evt = EventFrame( + type: "event", + event: "device.pair.requested", + payload: MoltbotProtocol.AnyCodable(payload), + seq: nil, + stateversion: nil) + + await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId) + + #expect(appModel.gatewayPairingState == .none) + } + + @Test @MainActor func pairingResolvedEventClearsPending() async { + let appModel = NodeAppModel() + let myDeviceId = "test-device-789" + + // First set pending state + appModel._test_setPairingPending(role: .operator, pending: true) + appModel._test_setPairingPending(role: .node, pending: true) + #expect(appModel.gatewayPairingState == .bothPending) + + // Receive resolved event + let payload: [String: MoltbotProtocol.AnyCodable] = [ + "deviceId": MoltbotProtocol.AnyCodable(myDeviceId), + "decision": MoltbotProtocol.AnyCodable("approved"), + ] + let evt = EventFrame( + type: "event", + event: "device.pair.resolved", + payload: MoltbotProtocol.AnyCodable(payload), + seq: nil, + stateversion: nil) + + await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId) + + #expect(appModel.gatewayPairingState == .none) + } + + @Test @MainActor func pairingResolvedEventIgnoresOtherDevices() async { + let appModel = NodeAppModel() + let myDeviceId = "test-device-mine" + + // Set pending state + appModel._test_setPairingPending(role: .operator, pending: true) + #expect(appModel.gatewayPairingState == .operatorPending) + + // Receive resolved event for different device + let payload: [String: MoltbotProtocol.AnyCodable] = [ + "deviceId": MoltbotProtocol.AnyCodable("different-device"), + "decision": MoltbotProtocol.AnyCodable("approved"), + ] + let evt = EventFrame( + type: "event", + event: "device.pair.resolved", + payload: MoltbotProtocol.AnyCodable(payload), + seq: nil, + stateversion: nil) + + await appModel._test_handlePairingEvent(evt, myDeviceId: myDeviceId) + + // State unchanged + #expect(appModel.gatewayPairingState == .operatorPending) + } +} diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index cd3fb2ff0..ae1c473d5 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -4,8 +4,9 @@ import Testing @Suite struct IOSGatewayChatTransportTests { @Test func requestsFailFastWhenGatewayNotConnected() async { - let gateway = GatewayNodeSession() - let transport = IOSGatewayChatTransport(gateway: gateway) + let gateway = GatewayOperatorSession() + let nodeSession = GatewayNodeSession() + let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession) do { _ = try await transport.requestHistory(sessionKey: "node-test") diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 0172812a5..bd9048f88 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -67,8 +67,10 @@ import UIKit @Test @MainActor func chatSheetBuildsAViewHierarchy() { let appModel = NodeAppModel() - let gateway = GatewayNodeSession() - let root = ChatSheet(gateway: gateway, sessionKey: "test") + let root = ChatSheet( + gateway: appModel.gatewaySession, + nodeSession: appModel.gatewayNodeSession, + sessionKey: "test") .environment(appModel) .environment(appModel.voiceWake) _ = Self.host(root) diff --git a/apps/ios/Tests/TestGatewayWebSocketSession.swift b/apps/ios/Tests/TestGatewayWebSocketSession.swift new file mode 100644 index 000000000..b51504b1c --- /dev/null +++ b/apps/ios/Tests/TestGatewayWebSocketSession.swift @@ -0,0 +1,220 @@ +import MoltbotKit +import Foundation +import os + +final class TestGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let connectRequestID = OSAllocatedUnfairLock(initialState: nil) + private let connectParamsData = OSAllocatedUnfairLock(initialState: nil) + private let requestMethods = OSAllocatedUnfairLock<[String]>(initialState: []) + private let pendingReceiveHandler = + OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>( + initialState: nil) + private let queuedMessages = OSAllocatedUnfairLock<[URLSessionWebSocketTask.Message]>(initialState: []) + private let sentChallenge = OSAllocatedUnfairLock(initialState: false) + + var state: URLSessionTask.State = .suspended + + func snapshotConnectParams() -> [String: Any]? { + guard let data = self.connectParamsData.withLock({ $0 }) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + func snapshotRequestMethods() -> [String] { + self.requestMethods.withLock { $0 } + } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + let handler = self.pendingReceiveHandler.withLock { handler in + defer { handler = nil } + return handler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + let data: Data? = switch message { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "req", + let id = obj["id"] as? String, + let method = obj["method"] as? String + else { return } + + if method == "connect" { + self.connectRequestID.withLock { $0 = id } + let paramsData = obj["params"].flatMap { try? JSONSerialization.data(withJSONObject: $0) } + self.connectParamsData.withLock { $0 = paramsData } + return + } + + self.requestMethods.withLock { $0.append(method) } + guard let responseData = Self.responseData(for: method, id: id) else { return } + self.deliver(.data(responseData)) + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + if self.sentChallenge.withLock({ $0 == false }) { + self.sentChallenge.withLock { $0 = true } + return .data(Self.connectChallengeData()) + } + let id = self.connectRequestID.withLock { $0 } ?? "connect" + return .data(Self.connectOkData(id: id)) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + let message = self.queuedMessages.withLock { messages -> URLSessionWebSocketTask.Message? in + guard !messages.isEmpty else { return nil } + return messages.removeFirst() + } + if let message { + completionHandler(.success(message)) + return + } + self.pendingReceiveHandler.withLock { $0 = completionHandler } + } + + private func deliver(_ message: URLSessionWebSocketTask.Message) { + let handler = self.pendingReceiveHandler.withLock { handler -> ((@Sendable (Result) -> Void))? in + let h = handler + handler = nil + return h + } + if let handler { + handler(.success(message)) + } else { + self.queuedMessages.withLock { $0.append(message) } + } + } + + private static func connectChallengeData() -> Data { + let json = """ + { + "type": "event", + "event": "connect.challenge", + "payload": { "nonce": "test-nonce" } + } + """ + return Data(json.utf8) + } + + private static func connectOkData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 3, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + } + """ + return Data(json.utf8) + } + + private static func responseData(for method: String, id: String) -> Data? { + switch method { + case "chat.send": + return Data(""" + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { "runId": "run-1", "status": "ok" } + } + """.utf8) + case "chat.history": + return Data(""" + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "sessionKey": "main", + "sessionId": null, + "messages": [], + "thinkingLevel": "low" + } + } + """.utf8) + case "health": + return Data(""" + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { "ok": true } + } + """.utf8) + case "config.get": + return Data(""" + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "config": { + "ui": { "seamColor": "#ffffff" }, + "session": { "mainKey": "main" } + } + } + } + """.utf8) + case "voicewake.get": + return Data(""" + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { "triggers": ["clawd"] } + } + """.utf8) + default: + return nil + } + } +} + +final class TestGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let taskLock = OSAllocatedUnfairLock<[TestGatewayWebSocketTask]>(initialState: []) + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + let task = TestGatewayWebSocketTask() + self.taskLock.withLock { $0.append(task) } + return WebSocketTaskBox(task: task) + } + + func snapshotConnectRoles() -> [String] { + self.taskLock.withLock { tasks in + tasks.compactMap { $0.snapshotConnectParams()?["role"] as? String } + } + } + + func snapshotRequestMethods() -> [String] { + self.taskLock.withLock { tasks in + tasks.flatMap { $0.snapshotRequestMethods() } + } + } +} diff --git a/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift index 6ef6c71b6..0199e41b8 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatViewModel.swift @@ -408,7 +408,10 @@ public final class MoltbotChatViewModel { } private func handleAgentEvent(_ evt: MoltbotAgentEventPayload) { - if let sessionId, evt.runId != sessionId { + // Agent events use runId (client idempotency key), not sessionId (Pi session UUID). + // Match against our pending runs, not the session ID. + let isOurRun = self.pendingRuns.contains(evt.runId) + if !isOurRun { return } diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift index 0ead3021c..cf0f56733 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayChannel.swift @@ -128,7 +128,7 @@ public actor GatewayChannelActor { private let decoder = JSONDecoder() private let encoder = JSONEncoder() private let connectTimeoutSeconds: Double = 6 - private let connectChallengeTimeoutSeconds: Double = 0.75 + private let connectChallengeTimeoutSeconds: Double = 2.0 private var watchdogTask: Task? private var tickTask: Task? private let defaultRequestTimeoutMs: Double = 15000 @@ -247,16 +247,11 @@ public actor GatewayChannelActor { self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)") throw wrapped } - self.listen() - self.connected = true + // Note: listen(), connected, isConnecting, and connectWaiters are now + // handled inside handleConnectResponse() before pushHandler is called, + // so that nested requests from onConnected callbacks can proceed. self.backoffMs = 500 self.lastSeq = nil - - let waiters = self.connectWaiters - self.connectWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: ()) - } } private func sendConnect() async throws { @@ -416,6 +411,16 @@ public actor GatewayChannelActor { guard let self else { return } await self.watchTicks() } + // Mark as connected and start listening BEFORE calling pushHandler, + // so that any requests made from onConnected callbacks can proceed. + self.listen() + self.connected = true + self.isConnecting = false + let waiters = self.connectWaiters + self.connectWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: ()) + } await self.pushHandler?(.snapshot(ok)) } @@ -477,28 +482,23 @@ public actor GatewayChannelActor { private func waitForConnectChallenge() async throws -> String? { guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { [weak self] in - guard let self else { return nil } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String { - return nonce - } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { return nil } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge" { + if let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String { + return nonce } } - }) - } catch { - if error is ConnectChallengeError { return nil } - throw error - } + } + }) } private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { diff --git a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift index 570342ce4..2eaffd418 100644 --- a/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift +++ b/apps/shared/MoltbotKit/Sources/MoltbotKit/GatewayNodeSession.swift @@ -191,10 +191,10 @@ public actor GatewayNodeSession { self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } guard let payload = evt.payload else { return } + guard let onInvoke = self.onInvoke else { return } do { let data = try self.encoder.encode(payload) let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) - guard let onInvoke else { return } let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) let response = await Self.invokeWithTimeout( request: req, diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index ef644281c..988cadde7 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -23,24 +23,47 @@ Availability: internal preview. The iOS app is not publicly distributed yet. - Tailnet via unicast DNS-SD (`moltbot.internal.`), **or** - Manual host/port (fallback). -## Quick start (pair + connect) +## Quick start (authenticate + pair + connect) -1) Start the Gateway: +### 1. Start the Gateway ```bash moltbot gateway --port 18789 ``` -2) In the iOS app, open Settings and pick a discovered gateway (or enable Manual Host and enter host/port). +### 2. Configure authentication in the iOS app -3) Approve the pairing request on the gateway host: +Open Settings in the Moltbot iOS app and configure **one** of the following: + +- **Gateway Token**: Paste the token from `moltbot config get gateway.token` (recommended for secure setups) +- **Gateway Password**: Enter the password from `moltbot config get gateway.password` (simpler alternative) + +If neither is set on the gateway, you can set one: ```bash -moltbot nodes pending -moltbot nodes approve +moltbot config set gateway.token "your-secret-token" +# or +moltbot config set gateway.password "your-password" ``` -4) Verify connection: +### 3. Select the gateway + +In the iOS app Settings, pick a discovered gateway from the list, or enable **Manual Host** and enter the host/port manually. + +### 4. Approve the pairing request + +The iOS app requires device pairing for both operator and node roles. On the gateway host, list and approve pending devices: + +```bash +moltbot devices list +moltbot devices approve +``` + +You may need to approve twice (once for operator role, once for node role). + +> **Note**: Use `moltbot devices` for WebSocket pairing, not `moltbot nodes pending/approve`. + +### 5. Verify connection ```bash moltbot nodes status @@ -94,8 +117,9 @@ moltbot nodes invoke --node "iOS Node" --command canvas.snapshot --params '{"max - `NODE_BACKGROUND_UNAVAILABLE`: bring the iOS app to the foreground (canvas/camera/screen commands require it). - `A2UI_HOST_NOT_CONFIGURED`: the Gateway did not advertise a canvas host URL; check `canvasHost` in [Gateway configuration](/gateway/configuration). -- Pairing prompt never appears: run `moltbot nodes pending` and approve manually. -- Reconnect fails after reinstall: the Keychain pairing token was cleared; re-pair the node. +- **Authentication failed**: Ensure the gateway token or password in iOS Settings matches what is configured on the gateway (`moltbot config get gateway.token` or `gateway.password`). +- **Pairing prompt never appears**: Run `moltbot devices list` to see pending requests and approve with `moltbot devices approve `. +- **Reconnect fails after reinstall**: The Keychain pairing token was cleared; re-pair the device using `moltbot devices list` and `moltbot devices approve`. ## Related docs