Merge 6e33f3f0f3 into 4583f88626
This commit is contained in:
commit
77410dc0a0
@ -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