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:
parent
32cb42b843
commit
6e33f3f0f3
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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])
|
||||||
|
}
|
||||||
|
|||||||
163
apps/ios/Sources/Gateway/GatewayOperatorSession.swift
Normal file
163
apps/ios/Sources/Gateway/GatewayOperatorSession.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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") ||
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)\"}",
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
apps/ios/Tests/GatewayDualSessionStateTests.swift
Normal file
52
apps/ios/Tests/GatewayDualSessionStateTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apps/ios/Tests/GatewayPairingStateTests.swift
Normal file
146
apps/ios/Tests/GatewayPairingStateTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
220
apps/ios/Tests/TestGatewayWebSocketSession.swift
Normal file
220
apps/ios/Tests/TestGatewayWebSocketSession.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user