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