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.
This commit is contained in:
Chris Herold 2026-01-27 09:59:47 -08:00
parent 32cb42b843
commit 6e33f3f0f3
No known key found for this signature in database
GPG Key ID: CF64BFBB6D4A3FCC
21 changed files with 1438 additions and 211 deletions

View File

@ -7,8 +7,13 @@ struct ChatSheet: View {
@State private var viewModel: MoltbotChatViewModel @State private var viewModel: MoltbotChatViewModel
private let userAccent: Color? private let userAccent: Color?
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) { init(
let transport = IOSGatewayChatTransport(gateway: gateway) gateway: GatewayOperatorSession,
nodeSession: GatewayNodeSession,
sessionKey: String,
userAccent: Color? = nil
) {
let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession)
self._viewModel = State( self._viewModel = State(
initialValue: MoltbotChatViewModel( initialValue: MoltbotChatViewModel(
sessionKey: sessionKey, sessionKey: sessionKey,

View File

@ -4,10 +4,13 @@ import MoltbotProtocol
import Foundation import Foundation
struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable { 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.gateway = gateway
self.nodeSession = nodeSession
} }
func abortRun(sessionKey: String, runId: String) async throws { func abortRun(sessionKey: String, runId: String) async throws {
@ -36,7 +39,8 @@ struct IOSGatewayChatTransport: MoltbotChatTransport, Sendable {
struct Subscribe: Codable { var sessionKey: String } struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
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 { func requestHistory(sessionKey: String) async throws -> MoltbotChatHistoryPayload {

View File

@ -205,7 +205,8 @@ final class GatewayConnectionController {
password: String?) password: String?)
{ {
guard let appModel else { return } guard let appModel else { return }
let connectOptions = self.makeConnectOptions() let operatorConnectOptions = self.makeOperatorConnectOptions()
let nodeConnectOptions = self.makeNodeConnectOptions()
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@ -218,7 +219,8 @@ final class GatewayConnectionController {
tls: tls, tls: tls,
token: token, token: token,
password: password, password: password,
connectOptions: connectOptions) operatorConnectOptions: operatorConnectOptions,
nodeConnectOptions: nodeConnectOptions)
} }
} }
@ -273,10 +275,28 @@ final class GatewayConnectionController {
"manual|\(host.lowercased())|\(port)" "manual|\(host.lowercased())|\(port)"
} }
private func makeConnectOptions() -> GatewayConnectOptions { private func makeOperatorConnectOptions() -> GatewayConnectOptions {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults) 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( return GatewayConnectOptions(
role: "node", role: "node",
scopes: [], scopes: [],
@ -288,6 +308,12 @@ final class GatewayConnectionController {
clientDisplayName: displayName) 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 { private func resolvedDisplayName(defaults: UserDefaults) -> String {
let key = "node.displayName" let key = "node.displayName"
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -324,6 +350,10 @@ final class GatewayConnectionController {
} }
private func currentCommands() -> [String] { 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] = [ var commands: [String] = [
MoltbotCanvasCommand.present.rawValue, MoltbotCanvasCommand.present.rawValue,
MoltbotCanvasCommand.hide.rawValue, MoltbotCanvasCommand.hide.rawValue,
@ -334,11 +364,6 @@ final class GatewayConnectionController {
MoltbotCanvasA2UICommand.pushJSONL.rawValue, MoltbotCanvasA2UICommand.pushJSONL.rawValue,
MoltbotCanvasA2UICommand.reset.rawValue, MoltbotCanvasA2UICommand.reset.rawValue,
MoltbotScreenCommand.record.rawValue, MoltbotScreenCommand.record.rawValue,
MoltbotSystemCommand.notify.rawValue,
MoltbotSystemCommand.which.rawValue,
MoltbotSystemCommand.run.rawValue,
MoltbotSystemCommand.execApprovalsGet.rawValue,
MoltbotSystemCommand.execApprovalsSet.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())

View File

@ -33,6 +33,9 @@ final class GatewayDiscoveryModel {
private var browsers: [String: NWBrowser] = [:] private var browsers: [String: NWBrowser] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
private var resolvedTXTByID: [String: [String: String]] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:] private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>() private var lastStableIDs = Set<String>()
@ -71,34 +74,8 @@ final class GatewayDiscoveryModel {
browser.browseResultsChangedHandler = { [weak self] results, _ in browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in self.resultsByDomain[domain] = results
switch result.endpoint { self.updateGatewaysForDomain(domain: domain, results: results)
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.recomputeGateways() self.recomputeGateways()
} }
} }
@ -115,11 +92,106 @@ final class GatewayDiscoveryModel {
} }
self.browsers = [:] self.browsers = [:]
self.gatewaysByDomain = [:] self.gatewaysByDomain = [:]
self.resultsByDomain = [:]
self.resolvedTXTByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:]
self.statesByDomain = [:] self.statesByDomain = [:]
self.gateways = [] self.gateways = []
self.statusText = "Stopped" 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<NWBrowser.Result>) {
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() { private func recomputeGateways() {
let next = self.gatewaysByDomain.values let next = self.gatewaysByDomain.values
.flatMap(\.self) .flatMap(\.self)
@ -222,3 +294,65 @@ final class GatewayDiscoveryModel {
return raw == "1" || raw == "true" || raw == "yes" 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])
}

View File

@ -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<EventFrame>.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<EventFrame> {
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)
}
}
}

View File

@ -1,4 +1,5 @@
import MoltbotKit import MoltbotKit
import MoltbotProtocol
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
@ -14,6 +15,18 @@ final class NodeAppModel {
case error case error
} }
enum ConnectionRole: String {
case `operator`
case node
}
enum GatewayPairingState: Equatable {
case none
case operatorPending
case nodePending
case bothPending
}
var isBackgrounded: Bool = false var isBackgrounded: Bool = false
let screen = ScreenController() let screen = ScreenController()
let camera = CameraController() let camera = CameraController()
@ -23,26 +36,52 @@ final class NodeAppModel {
var gatewayRemoteAddress: String? var gatewayRemoteAddress: String?
var connectedGatewayID: String? var connectedGatewayID: String?
var seamColorHex: 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() var gatewayPairingState: GatewayPairingState {
private var gatewayTask: Task<Void, Never>? 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<Void, Never>?
private var nodeTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>? private var voiceWakeSyncTask: Task<Void, Never>?
private var pairingEventTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
let talkMode = TalkModeManager() let talkMode = TalkModeManager()
private let locationService = LocationService() private let locationService = LocationService()
private var lastAutoA2uiURL: String? private var lastAutoA2uiURL: String?
private var gatewayConnected = false var operatorConnected: Bool = false
var gatewaySession: GatewayNodeSession { self.gateway } var nodeConnected: Bool = false
var gatewaySession: GatewayOperatorSession { self.gateway }
var gatewayNodeSession: GatewayNodeSession { self.nodeSession }
var cameraHUDText: String? var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind? var cameraHUDKind: CameraHUDKind?
var cameraFlashNonce: Int = 0 var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false 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 self.voiceWake.configure { [weak self] cmd in
guard let self else { return } guard let self else { return }
let sessionKey = await MainActor.run { self.mainSessionKey } let sessionKey = await MainActor.run { self.mainSessionKey }
@ -55,7 +94,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled) self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.gateway) self.talkMode.attachGateway(self.gateway, nodeSession: self.nodeSession)
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.talkMode.setEnabled(talkEnabled) self.talkMode.setEnabled(talkEnabled)
@ -151,7 +190,7 @@ final class NodeAppModel {
} }
private func resolveA2UIHostURL() async -> String? { 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) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=ios" return base.appendingPathComponent("__moltbot__/a2ui/").absoluteString + "?platform=ios"
@ -209,121 +248,349 @@ final class NodeAppModel {
tls: GatewayTLSParams?, tls: GatewayTLSParams?,
token: String?, token: String?,
password: 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.gatewayServerName = nil
self.gatewayRemoteAddress = nil self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
self.connectedGatewayID = id.isEmpty ? url.absoluteString : id self.connectedGatewayID = id.isEmpty ? url.absoluteString : id
self.gatewayConnected = false self.operatorConnected = false
self.voiceWakeSyncTask?.cancel() self.nodeConnected = false
self.voiceWakeSyncTask = nil self.clearPairingPending()
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } self.gatewayStatusText = "Connecting…"
self.gatewayTask = Task { // Create separate session boxes for operator and node to avoid shared websocket state.
var attempt = 0 // Each connection needs its own URLSession/TLS session to prevent response cross-talk.
while !Task.isCancelled { func makeSessionBox() -> WebSocketSessionBox? {
await MainActor.run { if let sessionBox { return sessionBox }
if attempt == 0 { if let tls { return WebSocketSessionBox(session: GatewayTLSPinningSession(params: tls)) }
self.gatewayStatusText = "Connecting…" return nil
} else { }
self.gatewayStatusText = "Reconnecting…"
}
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
}
do { // Start independent connection loops for operator and node
try await self.gateway.connect( self.operatorTask = Task { [weak self] in
url: url, await self?.operatorConnectLoop(
token: token, url: url,
password: password, token: token,
connectOptions: connectOptions, password: password,
sessionBox: sessionBox, connectOptions: operatorConnectOptions,
onConnected: { [weak self] in sessionBox: makeSessionBox())
guard let self else { return } }
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 { await MainActor.run {
self.gatewayStatusText = "Connected" self.gatewayRemoteAddress = addr
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
} }
if let addr = await self.gateway.currentRemoteAddress() { }
await MainActor.run { await self.refreshBrandingFromGateway()
self.gatewayRemoteAddress = addr await self.startVoiceWakeSync()
} await self.startPairingEventSync()
} },
await self.refreshBrandingFromGateway() onDisconnected: { [weak self] reason in
await self.startVoiceWakeSync() guard let self else { return }
await self.showA2UIOnConnectIfNeeded() await MainActor.run {
}, self.operatorConnected = false
onDisconnected: { [weak self] reason in self.gatewayRemoteAddress = nil
guard let self else { return } if !self.nodeConnected {
await MainActor.run { self.gatewayServerName = nil
self.gatewayStatusText = "Disconnected"
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
self.gatewayStatusText = "Disconnected: \(reason)"
} }
}, self.updateGatewayConnectionStatus(reason: reason)
onInvoke: { [weak self] req in self.updatePairingPending(for: .operator, reason: reason)
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)
})
if Task.isCancelled { break } // Connection succeeded - reset attempt counter and wait before checking again
attempt = 0 attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled { break } } catch {
attempt += 1 if Task.isCancelled { break }
await MainActor.run { attempt += 1
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" await MainActor.run {
self.gatewayServerName = nil self.updatePairingPending(for: .operator, reason: error.localizedDescription)
self.gatewayRemoteAddress = nil self.operatorConnected = false
self.gatewayConnected = false self.updateGatewayConnectionStatus(reason: error.localizedDescription)
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))
} }
// 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 { // Cleanup on task cancellation
self.gatewayStatusText = "Offline" await self.gateway.disconnect()
self.gatewayServerName = nil await MainActor.run {
self.gatewayRemoteAddress = nil self.operatorConnected = false
self.connectedGatewayID = nil self.setPairingPending(for: .operator, pending: false)
self.gatewayConnected = false self.updateGatewayConnectionStatus()
self.seamColorHex = nil }
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { }
self.mainSessionKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey) /// 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() { func disconnectGateway() {
self.gatewayTask?.cancel() self.operatorTask?.cancel()
self.gatewayTask = nil self.operatorTask = nil
self.nodeTask?.cancel()
self.nodeTask = nil
self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil 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.gatewayStatusText = "Offline"
self.gatewayServerName = nil self.gatewayServerName = nil
self.gatewayRemoteAddress = nil self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil self.connectedGatewayID = nil
self.gatewayConnected = false self.operatorConnected = false
self.nodeConnected = false
self.clearPairingPending()
self.seamColorHex = nil self.seamColorHex = nil
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = "main" self.mainSessionKey = "main"
@ -445,7 +712,7 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", 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 { func handleDeepLink(url: URL) async {
@ -494,11 +761,11 @@ final class NodeAppModel {
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", 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 { private func isGatewayConnected() async -> Bool {
self.gatewayConnected self.operatorConnected
} }
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
@ -705,7 +972,7 @@ final class NodeAppModel {
""") """)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case MoltbotCanvasA2UICommand.push.rawValue, MoltbotCanvasA2UICommand.pushJSONL.rawValue: case MoltbotCanvasA2UICommand.push.rawValue, MoltbotCanvasA2UICommand.pushJSONL.rawValue:
let messages: [AnyCodable] let messages: [MoltbotKit.AnyCodable]
if command == MoltbotCanvasA2UICommand.pushJSONL.rawValue { if command == MoltbotCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(MoltbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON) let params = try Self.decodeParams(MoltbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try MoltbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl) messages = try MoltbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
@ -955,5 +1222,21 @@ extension NodeAppModel {
func _test_showLocalCanvasOnDisconnect() { func _test_showLocalCanvasOnDisconnect() {
self.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 #endif

View File

@ -53,6 +53,7 @@ struct RootCanvas: View {
case .chat: case .chat:
ChatSheet( ChatSheet(
gateway: self.appModel.gatewaySession, gateway: self.appModel.gatewaySession,
nodeSession: self.appModel.gatewayNodeSession,
sessionKey: self.appModel.mainSessionKey, sessionKey: self.appModel.mainSessionKey,
userAccent: self.appModel.seamColor) 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 { 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) let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") || if text.localizedCaseInsensitiveContains("connecting") ||

View File

@ -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 { 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) let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") || if text.localizedCaseInsensitiveContains("connecting") ||
@ -90,6 +121,10 @@ struct RootTabs: View {
tint: .orange) tint: .orange)
} }
if self.pairingRole != nil {
return nil
}
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased() let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") { if gatewayLower.contains("repair") {

View File

@ -65,8 +65,22 @@ struct SettingsTab: View {
} }
Section("Gateway") { 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) 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 { if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName) LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress { if let addr = self.appModel.gatewayRemoteAddress {
@ -120,7 +134,7 @@ struct SettingsTab: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
TextField("Port", value: self.$manualGatewayPort, format: .number) TextField("Port", value: self.$manualGatewayPort, formatter: Self.portFormatter)
.keyboardType(.numberPad) .keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS) Toggle("Use TLS", isOn: self.$manualGatewayTLS)
@ -158,12 +172,6 @@ struct SettingsTab: View {
} }
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) 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) useTLS: self.manualGatewayTLS)
} }
private static let portFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.usesGroupingSeparator = false
return formatter
}()
private static func primaryIPv4Address() -> String? { private static func primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>? var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
@ -458,7 +473,7 @@ struct SettingsTab: View {
} }
if lines.isEmpty { if lines.isEmpty {
lines.append(gateway.debugID) lines.append("Discovery details unavailable yet.")
} }
return lines return lines

View File

@ -3,16 +3,52 @@ import SwiftUI
struct StatusPill: View { struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase @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 { enum GatewayState: Equatable {
case connected case connected(ConnectionRole)
case connecting case connecting
case pairingPending(PairingRole)
case error case error
case disconnected case disconnected
var title: String { var title: String {
switch self { switch self {
case .connected: "Connected" case let .connected(role): role.title
case .connecting: "Connecting…" case .connecting: "Connecting…"
case let .pairingPending(role): role.title
case .error: "Error" case .error: "Error"
case .disconnected: "Offline" case .disconnected: "Offline"
} }
@ -22,10 +58,16 @@ struct StatusPill: View {
switch self { switch self {
case .connected: .green case .connected: .green
case .connecting: .yellow case .connecting: .yellow
case .pairingPending: .orange
case .error: .red case .error: .red
case .disconnected: .gray case .disconnected: .gray
} }
} }
var isConnecting: Bool {
if case .connecting = self { return true }
return false
}
} }
struct Activity: Equatable { struct Activity: Equatable {
@ -49,8 +91,8 @@ struct StatusPill: View {
Circle() Circle()
.fill(self.gateway.color) .fill(self.gateway.color)
.frame(width: 9, height: 9) .frame(width: 9, height: 9)
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) .scaleEffect(self.gateway.isConnecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) .opacity(self.gateway.isConnecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title) Text(self.gateway.title)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
@ -114,7 +156,7 @@ struct StatusPill: View {
} }
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { 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 } withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
return return
} }

View File

@ -43,15 +43,17 @@ final class TalkModeManager: NSObject {
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.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 let silenceWindow: TimeInterval = 0.7
private var chatSubscribedSessionKeys = Set<String>() private var chatSubscribedSessionKeys = Set<String>()
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
func attachGateway(_ gateway: GatewayNodeSession) { func attachGateway(_ gateway: GatewayOperatorSession, nodeSession: GatewayNodeSession? = nil) {
self.gateway = gateway self.gateway = gateway
self.nodeSession = nodeSession
} }
func updateMainSessionKey(_ sessionKey: String?) { func updateMainSessionKey(_ sessionKey: String?) {
@ -298,22 +300,23 @@ final class TalkModeManager: NSObject {
private func subscribeChatIfNeeded(sessionKey: String) async { private func subscribeChatIfNeeded(sessionKey: String) async {
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return } guard !key.isEmpty else { return }
guard let gateway else { return } // Use nodeSession for node.event (chat.subscribe).
guard let nodeSession else { return }
guard !self.chatSubscribedSessionKeys.contains(key) else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return }
let payload = "{\"sessionKey\":\"\(key)\"}" 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.chatSubscribedSessionKeys.insert(key)
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
} }
private func unsubscribeAllChats() async { private func unsubscribeAllChats() async {
guard let gateway else { return } guard let nodeSession else { return }
let keys = self.chatSubscribedSessionKeys let keys = self.chatSubscribedSessionKeys
self.chatSubscribedSessionKeys.removeAll() self.chatSubscribedSessionKeys.removeAll()
for key in keys { for key in keys {
let payload = "{\"sessionKey\":\"\(key)\"}" 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 } struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [ let payload: [String: Any] = [
"sessionKey": self.mainSessionKey, "sessionKey": self.mainSessionKey,
@ -362,7 +365,7 @@ final class TalkModeManager: NSObject {
private func waitForChatCompletion( private func waitForChatCompletion(
runId: String, runId: String,
gateway: GatewayNodeSession, gateway: GatewayOperatorSession,
timeoutSeconds: Int = 120) async -> ChatCompletionState timeoutSeconds: Int = 120) async -> ChatCompletionState
{ {
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
@ -397,7 +400,7 @@ final class TalkModeManager: NSObject {
} }
private func waitForAssistantText( private func waitForAssistantText(
gateway: GatewayNodeSession, gateway: GatewayOperatorSession,
since: Double, since: Double,
timeoutSeconds: Int) async throws -> String? timeoutSeconds: Int) async throws -> String?
{ {
@ -411,7 +414,10 @@ final class TalkModeManager: NSObject {
return nil 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( let res = try await gateway.request(
method: "chat.history", method: "chat.history",
paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}", paramsJSON: "{\"sessionKey\":\"\(self.mainSessionKey)\"}",

View File

@ -76,4 +76,39 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(MoltbotLocationCommand.get.rawValue)) #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))
}
}
} }

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -4,8 +4,9 @@ import Testing
@Suite struct IOSGatewayChatTransportTests { @Suite struct IOSGatewayChatTransportTests {
@Test func requestsFailFastWhenGatewayNotConnected() async { @Test func requestsFailFastWhenGatewayNotConnected() async {
let gateway = GatewayNodeSession() let gateway = GatewayOperatorSession()
let transport = IOSGatewayChatTransport(gateway: gateway) let nodeSession = GatewayNodeSession()
let transport = IOSGatewayChatTransport(gateway: gateway, nodeSession: nodeSession)
do { do {
_ = try await transport.requestHistory(sessionKey: "node-test") _ = try await transport.requestHistory(sessionKey: "node-test")

View File

@ -67,8 +67,10 @@ import UIKit
@Test @MainActor func chatSheetBuildsAViewHierarchy() { @Test @MainActor func chatSheetBuildsAViewHierarchy() {
let appModel = NodeAppModel() let appModel = NodeAppModel()
let gateway = GatewayNodeSession() let root = ChatSheet(
let root = ChatSheet(gateway: gateway, sessionKey: "test") gateway: appModel.gatewaySession,
nodeSession: appModel.gatewayNodeSession,
sessionKey: "test")
.environment(appModel) .environment(appModel)
.environment(appModel.voiceWake) .environment(appModel.voiceWake)
_ = Self.host(root) _ = Self.host(root)

View File

@ -0,0 +1,220 @@
import MoltbotKit
import Foundation
import os
final class TestGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let connectParamsData = OSAllocatedUnfairLock<Data?>(initialState: nil)
private let requestMethods = OSAllocatedUnfairLock<[String]>(initialState: [])
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>.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<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>) -> 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() }
}
}
}

View File

@ -408,7 +408,10 @@ public final class MoltbotChatViewModel {
} }
private func handleAgentEvent(_ evt: MoltbotAgentEventPayload) { 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 return
} }

View File

@ -128,7 +128,7 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6 private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75 private let connectChallengeTimeoutSeconds: Double = 2.0
private var watchdogTask: Task<Void, Never>? private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>? private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000 private let defaultRequestTimeoutMs: Double = 15000
@ -247,16 +247,11 @@ public actor GatewayChannelActor {
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)") self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
throw wrapped throw wrapped
} }
self.listen() // Note: listen(), connected, isConnecting, and connectWaiters are now
self.connected = true // handled inside handleConnectResponse() before pushHandler is called,
// so that nested requests from onConnected callbacks can proceed.
self.backoffMs = 500 self.backoffMs = 500
self.lastSeq = nil self.lastSeq = nil
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(returning: ())
}
} }
private func sendConnect() async throws { private func sendConnect() async throws {
@ -416,6 +411,16 @@ public actor GatewayChannelActor {
guard let self else { return } guard let self else { return }
await self.watchTicks() 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)) await self.pushHandler?(.snapshot(ok))
} }
@ -477,28 +482,23 @@ public actor GatewayChannelActor {
private func waitForConnectChallenge() async throws -> String? { private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil } guard let task = self.task else { return nil }
do { return try await AsyncTimeout.withTimeout(
return try await AsyncTimeout.withTimeout( seconds: self.connectChallengeTimeoutSeconds,
seconds: self.connectChallengeTimeoutSeconds, onTimeout: { ConnectChallengeError.timeout },
onTimeout: { ConnectChallengeError.timeout }, operation: { [weak self] in
operation: { [weak self] in guard let self else { return nil }
guard let self else { return nil } while true {
while true { let msg = try await task.receive()
let msg = try await task.receive() guard let data = self.decodeMessageData(msg) else { continue }
guard let data = self.decodeMessageData(msg) else { continue } guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) 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 case let .event(evt) = frame, evt.event == "connect.challenge" { if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
if let payload = evt.payload?.value as? [String: ProtoAnyCodable], let nonce = payload["nonce"]?.value as? String {
let nonce = payload["nonce"]?.value as? String { return nonce
return nonce
}
} }
} }
}) }
} catch { })
if error is ConnectChallengeError { return nil }
throw error
}
} }
private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame {

View File

@ -191,10 +191,10 @@ public actor GatewayNodeSession {
self.broadcastServerEvent(evt) self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return } guard evt.event == "node.invoke.request" else { return }
guard let payload = evt.payload else { return } guard let payload = evt.payload else { return }
guard let onInvoke = self.onInvoke else { return }
do { do {
let data = try self.encoder.encode(payload) let data = try self.encoder.encode(payload)
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) 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 req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await Self.invokeWithTimeout( let response = await Self.invokeWithTimeout(
request: req, request: req,

View File

@ -23,24 +23,47 @@ Availability: internal preview. The iOS app is not publicly distributed yet.
- Tailnet via unicast DNS-SD (`moltbot.internal.`), **or** - Tailnet via unicast DNS-SD (`moltbot.internal.`), **or**
- Manual host/port (fallback). - Manual host/port (fallback).
## Quick start (pair + connect) ## Quick start (authenticate + pair + connect)
1) Start the Gateway: ### 1. Start the Gateway
```bash ```bash
moltbot gateway --port 18789 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 ```bash
moltbot nodes pending moltbot config set gateway.token "your-secret-token"
moltbot nodes approve <requestId> # 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 <id>
```
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 ```bash
moltbot nodes status 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). - `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). - `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. - **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`).
- Reconnect fails after reinstall: the Keychain pairing token was cleared; re-pair the node. - **Pairing prompt never appears**: Run `moltbot devices list` to see pending requests and approve with `moltbot devices approve <id>`.
- **Reconnect fails after reinstall**: The Keychain pairing token was cleared; re-pair the device using `moltbot devices list` and `moltbot devices approve`.
## Related docs ## Related docs