import ClawdbotKit 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 connectOptions = self.makeConnectOptions() 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, connectOptions: connectOptions) } } 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 makeConnectOptions() -> GatewayConnectOptions { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) return GatewayConnectOptions( role: "node", scopes: [], caps: self.currentCaps(), commands: self.currentCommands(), permissions: [:], clientId: "clawdbot-ios", clientMode: "node", clientDisplayName: displayName) } 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 = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.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(ClawdbotCapability.camera.rawValue) } let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) if voiceWakeEnabled { caps.append(ClawdbotCapability.voiceWake.rawValue) } let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" let locationMode = ClawdbotLocationMode(rawValue: locationModeRaw) ?? .off if locationMode != .off { caps.append(ClawdbotCapability.location.rawValue) } return caps } private func currentCommands() -> [String] { var commands: [String] = [ ClawdbotCanvasCommand.present.rawValue, ClawdbotCanvasCommand.hide.rawValue, ClawdbotCanvasCommand.navigate.rawValue, ClawdbotCanvasCommand.evalJS.rawValue, ClawdbotCanvasCommand.snapshot.rawValue, ClawdbotCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue, ClawdbotCanvasA2UICommand.reset.rawValue, ClawdbotScreenCommand.record.rawValue, ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.which.rawValue, ClawdbotSystemCommand.run.rawValue, ClawdbotSystemCommand.execApprovalsGet.rawValue, ClawdbotSystemCommand.execApprovalsSet.rawValue, ] let caps = Set(self.currentCaps()) if caps.contains(ClawdbotCapability.camera.rawValue) { commands.append(ClawdbotCameraCommand.list.rawValue) commands.append(ClawdbotCameraCommand.snap.rawValue) commands.append(ClawdbotCameraCommand.clip.rawValue) } if caps.contains(ClawdbotCapability.location.rawValue) { commands.append(ClawdbotLocationCommand.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