Aligns the iOS app with the Clawnet refactor by implementing proper role separation for gateway connections. Uses separate operator and node sessions to match the gateway's authorization requirements. Changes: - New GatewayOperatorSession: Wraps GatewayChannelActor for operator-role RPC requests (chat.*, health, sessions.list) without invoke handling - Dual-connection architecture: Operator session for requests, node session for node.event calls (e.g., chat.subscribe) - Separate websocket sessions: Each connection gets its own URLSession to prevent response cross-talk - Updated chat transport: IOSGatewayChatTransport uses operator session for requests, node session for subscriptions ClawdbotKit (shared): - Deadlock fix in GatewayChannel.swift: Moved connection finalization (listen(), connected=true, isConnecting=false, waiter resumption) to occur before calling pushHandler. This fixes a latent bug where requests made from onConnected callbacks would deadlock. Does not affect macOS (its callback doesn't make requests). - Package.swift: Fixed argument order for Swift 6.2 compatibility iOS chat is now working. This is the base PR to unlock further work on the iOS app.
460 lines
17 KiB
Swift
460 lines
17 KiB
Swift
import MoltbotKit
|
|
import Darwin
|
|
import Foundation
|
|
import Network
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class GatewayConnectionController {
|
|
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
|
private(set) var discoveryStatusText: String = "Idle"
|
|
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
|
|
|
private let discovery = GatewayDiscoveryModel()
|
|
private weak var appModel: NodeAppModel?
|
|
private var didAutoConnect = false
|
|
|
|
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
|
self.appModel = appModel
|
|
|
|
GatewaySettingsStore.bootstrapPersistence()
|
|
let defaults = UserDefaults.standard
|
|
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "gateway.discovery.debugLogs"))
|
|
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
|
|
if startDiscovery {
|
|
self.discovery.start()
|
|
}
|
|
}
|
|
|
|
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
|
|
self.discovery.setDebugLoggingEnabled(enabled)
|
|
}
|
|
|
|
func setScenePhase(_ phase: ScenePhase) {
|
|
switch phase {
|
|
case .background:
|
|
self.discovery.stop()
|
|
case .active, .inactive:
|
|
self.discovery.start()
|
|
@unknown default:
|
|
self.discovery.start()
|
|
}
|
|
}
|
|
|
|
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
|
guard let host = self.resolveGatewayHost(gateway) else { return }
|
|
let port = gateway.gatewayPort ?? 18789
|
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: gateway.stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
password: password)
|
|
}
|
|
|
|
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
|
let stableID = self.manualStableID(host: host, port: port)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
|
guard let url = self.buildGatewayURL(
|
|
host: host,
|
|
port: port,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
password: password)
|
|
}
|
|
|
|
private func updateFromDiscovery() {
|
|
let newGateways = self.discovery.gateways
|
|
self.gateways = newGateways
|
|
self.discoveryStatusText = self.discovery.statusText
|
|
self.discoveryDebugLog = self.discovery.debugLog
|
|
self.updateLastDiscoveredGateway(from: newGateways)
|
|
self.maybeAutoConnect()
|
|
}
|
|
|
|
private func observeDiscovery() {
|
|
withObservationTracking {
|
|
_ = self.discovery.gateways
|
|
_ = self.discovery.statusText
|
|
_ = self.discovery.debugLog
|
|
} onChange: { [weak self] in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
self.updateFromDiscovery()
|
|
self.observeDiscovery()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func maybeAutoConnect() {
|
|
guard !self.didAutoConnect else { return }
|
|
guard let appModel = self.appModel else { return }
|
|
guard appModel.gatewayServerName == nil else { return }
|
|
|
|
let defaults = UserDefaults.standard
|
|
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
|
|
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !instanceId.isEmpty else { return }
|
|
|
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
|
|
|
if manualEnabled {
|
|
let manualHost = defaults.string(forKey: "gateway.manual.host")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !manualHost.isEmpty else { return }
|
|
|
|
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
|
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
|
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
|
|
|
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
|
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
|
|
|
guard let url = self.buildGatewayURL(
|
|
host: manualHost,
|
|
port: resolvedPort,
|
|
useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
password: password)
|
|
return
|
|
}
|
|
|
|
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
|
guard let targetStableID = candidates.first(where: { id in
|
|
self.gateways.contains(where: { $0.stableID == id })
|
|
}) else { return }
|
|
|
|
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
|
guard let host = self.resolveGatewayHost(target) else { return }
|
|
let port = target.gatewayPort ?? 18789
|
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
|
else { return }
|
|
|
|
self.didAutoConnect = true
|
|
self.startAutoConnect(
|
|
url: url,
|
|
gatewayStableID: target.stableID,
|
|
tls: tlsParams,
|
|
token: token,
|
|
password: password)
|
|
}
|
|
|
|
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
let defaults = UserDefaults.standard
|
|
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
|
|
guard preferred.isEmpty, existingLast.isEmpty else { return }
|
|
guard let first = gateways.first else { return }
|
|
|
|
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
|
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(first.stableID)
|
|
}
|
|
|
|
private func startAutoConnect(
|
|
url: URL,
|
|
gatewayStableID: String,
|
|
tls: GatewayTLSParams?,
|
|
token: String?,
|
|
password: String?)
|
|
{
|
|
guard let appModel else { return }
|
|
let operatorConnectOptions = self.makeOperatorConnectOptions()
|
|
let nodeConnectOptions = self.makeNodeConnectOptions()
|
|
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await MainActor.run {
|
|
appModel.gatewayStatusText = "Connecting…"
|
|
}
|
|
appModel.connectToGateway(
|
|
url: url,
|
|
gatewayStableID: gatewayStableID,
|
|
tls: tls,
|
|
token: token,
|
|
password: password,
|
|
operatorConnectOptions: operatorConnectOptions,
|
|
nodeConnectOptions: nodeConnectOptions)
|
|
}
|
|
}
|
|
|
|
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
|
|
let stableID = gateway.stableID
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
|
|
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
|
|
allowTOFU: stored == nil,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
|
if tlsEnabled || stored != nil {
|
|
return GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: stored,
|
|
allowTOFU: stored == nil,
|
|
storeKey: stableID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
|
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
|
return lanHost
|
|
}
|
|
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
|
return tailnet
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
|
let scheme = useTLS ? "wss" : "ws"
|
|
var components = URLComponents()
|
|
components.scheme = scheme
|
|
components.host = host
|
|
components.port = port
|
|
return components.url
|
|
}
|
|
|
|
private func manualStableID(host: String, port: Int) -> String {
|
|
"manual|\(host.lowercased())|\(port)"
|
|
}
|
|
|
|
private func makeOperatorConnectOptions() -> GatewayConnectOptions {
|
|
let defaults = UserDefaults.standard
|
|
let displayName = self.resolvedDisplayName(defaults: defaults)
|
|
|
|
return GatewayConnectOptions(
|
|
role: "operator",
|
|
scopes: self.currentScopes(),
|
|
caps: [],
|
|
commands: [],
|
|
permissions: [:],
|
|
clientId: "moltbot-ios",
|
|
clientMode: "ui",
|
|
clientDisplayName: displayName)
|
|
}
|
|
|
|
private func makeNodeConnectOptions() -> GatewayConnectOptions {
|
|
let defaults = UserDefaults.standard
|
|
let displayName = self.resolvedDisplayName(defaults: defaults)
|
|
|
|
// Node role should not request operator scopes. The gateway treats scopes
|
|
// as operator-only permissions and each role has its own device token, so
|
|
// node connections should have empty scopes.
|
|
return GatewayConnectOptions(
|
|
role: "node",
|
|
scopes: [],
|
|
caps: self.currentCaps(),
|
|
commands: self.currentCommands(),
|
|
permissions: [:],
|
|
clientId: "moltbot-ios",
|
|
clientMode: "node",
|
|
clientDisplayName: displayName)
|
|
}
|
|
|
|
private func currentScopes() -> [String] {
|
|
// operator.read/write for chat operations.
|
|
// operator.pairing to receive device.pair.* events for pairing UI.
|
|
["operator.read", "operator.write", "operator.pairing"]
|
|
}
|
|
|
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
|
let key = "node.displayName"
|
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !existing.isEmpty, existing != "iOS Node" { return existing }
|
|
|
|
let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let candidate = deviceName.isEmpty ? "iOS Node" : deviceName
|
|
|
|
if existing.isEmpty || existing == "iOS Node" {
|
|
defaults.set(candidate, forKey: key)
|
|
}
|
|
|
|
return candidate
|
|
}
|
|
|
|
private func currentCaps() -> [String] {
|
|
var caps = [MoltbotCapability.canvas.rawValue, MoltbotCapability.screen.rawValue]
|
|
|
|
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
|
let cameraEnabled =
|
|
UserDefaults.standard.object(forKey: "camera.enabled") == nil
|
|
? true
|
|
: UserDefaults.standard.bool(forKey: "camera.enabled")
|
|
if cameraEnabled { caps.append(MoltbotCapability.camera.rawValue) }
|
|
|
|
let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey)
|
|
if voiceWakeEnabled { caps.append(MoltbotCapability.voiceWake.rawValue) }
|
|
|
|
let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
|
let locationMode = MoltbotLocationMode(rawValue: locationModeRaw) ?? .off
|
|
if locationMode != .off { caps.append(MoltbotCapability.location.rawValue) }
|
|
|
|
return caps
|
|
}
|
|
|
|
private func currentCommands() -> [String] {
|
|
// Only declare commands that match the gateway iOS allowlist.
|
|
// See: src/gateway/node-command-policy.ts - PLATFORM_DEFAULTS.ios
|
|
// iOS allowlist: CANVAS_COMMANDS + CAMERA_COMMANDS + SCREEN_COMMANDS + LOCATION_COMMANDS
|
|
// System commands (run, which, notify, execApprovals.*) are NOT on the iOS allowlist.
|
|
var commands: [String] = [
|
|
MoltbotCanvasCommand.present.rawValue,
|
|
MoltbotCanvasCommand.hide.rawValue,
|
|
MoltbotCanvasCommand.navigate.rawValue,
|
|
MoltbotCanvasCommand.evalJS.rawValue,
|
|
MoltbotCanvasCommand.snapshot.rawValue,
|
|
MoltbotCanvasA2UICommand.push.rawValue,
|
|
MoltbotCanvasA2UICommand.pushJSONL.rawValue,
|
|
MoltbotCanvasA2UICommand.reset.rawValue,
|
|
MoltbotScreenCommand.record.rawValue,
|
|
]
|
|
|
|
let caps = Set(self.currentCaps())
|
|
if caps.contains(MoltbotCapability.camera.rawValue) {
|
|
commands.append(MoltbotCameraCommand.list.rawValue)
|
|
commands.append(MoltbotCameraCommand.snap.rawValue)
|
|
commands.append(MoltbotCameraCommand.clip.rawValue)
|
|
}
|
|
if caps.contains(MoltbotCapability.location.rawValue) {
|
|
commands.append(MoltbotLocationCommand.get.rawValue)
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
private func platformString() -> String {
|
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
let name = switch UIDevice.current.userInterfaceIdiom {
|
|
case .pad:
|
|
"iPadOS"
|
|
case .phone:
|
|
"iOS"
|
|
default:
|
|
"iOS"
|
|
}
|
|
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
}
|
|
|
|
private func deviceFamily() -> String {
|
|
switch UIDevice.current.userInterfaceIdiom {
|
|
case .pad:
|
|
"iPad"
|
|
case .phone:
|
|
"iPhone"
|
|
default:
|
|
"iOS"
|
|
}
|
|
}
|
|
|
|
private func modelIdentifier() -> String {
|
|
var systemInfo = utsname()
|
|
uname(&systemInfo)
|
|
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
|
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
|
}
|
|
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? "unknown" : trimmed
|
|
}
|
|
|
|
private func appVersion() -> String {
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension GatewayConnectionController {
|
|
func _test_resolvedDisplayName(defaults: UserDefaults) -> String {
|
|
self.resolvedDisplayName(defaults: defaults)
|
|
}
|
|
|
|
func _test_currentCaps() -> [String] {
|
|
self.currentCaps()
|
|
}
|
|
|
|
func _test_currentCommands() -> [String] {
|
|
self.currentCommands()
|
|
}
|
|
|
|
func _test_platformString() -> String {
|
|
self.platformString()
|
|
}
|
|
|
|
func _test_deviceFamily() -> String {
|
|
self.deviceFamily()
|
|
}
|
|
|
|
func _test_modelIdentifier() -> String {
|
|
self.modelIdentifier()
|
|
}
|
|
|
|
func _test_appVersion() -> String {
|
|
self.appVersion()
|
|
}
|
|
|
|
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
|
self.gateways = gateways
|
|
}
|
|
|
|
func _test_triggerAutoConnect() {
|
|
self.maybeAutoConnect()
|
|
}
|
|
}
|
|
#endif
|