| import OpenClawKit |
| 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 ?? 7860 |
| 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 : 7860 |
| 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 ?? 7860 |
| 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) ?? "" |
|
|
| |
| 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: "openclaw-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 = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] |
|
|
| |
| let cameraEnabled = |
| UserDefaults.standard.object(forKey: "camera.enabled") == nil |
| ? true |
| : UserDefaults.standard.bool(forKey: "camera.enabled") |
| if cameraEnabled { caps.append(OpenClawCapability.camera.rawValue) } |
|
|
| let voiceWakeEnabled = UserDefaults.standard.bool(forKey: VoiceWakePreferences.enabledKey) |
| if voiceWakeEnabled { caps.append(OpenClawCapability.voiceWake.rawValue) } |
|
|
| let locationModeRaw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" |
| let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off |
| if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } |
|
|
| return caps |
| } |
|
|
| private func currentCommands() -> [String] { |
| var commands: [String] = [ |
| OpenClawCanvasCommand.present.rawValue, |
| OpenClawCanvasCommand.hide.rawValue, |
| OpenClawCanvasCommand.navigate.rawValue, |
| OpenClawCanvasCommand.evalJS.rawValue, |
| OpenClawCanvasCommand.snapshot.rawValue, |
| OpenClawCanvasA2UICommand.push.rawValue, |
| OpenClawCanvasA2UICommand.pushJSONL.rawValue, |
| OpenClawCanvasA2UICommand.reset.rawValue, |
| OpenClawScreenCommand.record.rawValue, |
| OpenClawSystemCommand.notify.rawValue, |
| OpenClawSystemCommand.which.rawValue, |
| OpenClawSystemCommand.run.rawValue, |
| OpenClawSystemCommand.execApprovalsGet.rawValue, |
| OpenClawSystemCommand.execApprovalsSet.rawValue, |
| ] |
|
|
| let caps = Set(self.currentCaps()) |
| if caps.contains(OpenClawCapability.camera.rawValue) { |
| commands.append(OpenClawCameraCommand.list.rawValue) |
| commands.append(OpenClawCameraCommand.snap.rawValue) |
| commands.append(OpenClawCameraCommand.clip.rawValue) |
| } |
| if caps.contains(OpenClawCapability.location.rawValue) { |
| commands.append(OpenClawLocationCommand.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 |
|
|