| import Foundation |
| import OpenClawKit |
| import OSLog |
|
|
| @MainActor |
| final class MacNodeModeCoordinator { |
| static let shared = MacNodeModeCoordinator() |
|
|
| private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node") |
| private var task: Task<Void, Never>? |
| private let runtime = MacNodeRuntime() |
| private let session = GatewayNodeSession() |
|
|
| func start() { |
| guard self.task == nil else { return } |
| self.task = Task { [weak self] in |
| await self?.run() |
| } |
| } |
|
|
| func stop() { |
| self.task?.cancel() |
| self.task = nil |
| Task { await self.session.disconnect() } |
| } |
|
|
| func setPreferredGatewayStableID(_ stableID: String?) { |
| GatewayDiscoveryPreferences.setPreferredStableID(stableID) |
| Task { await self.session.disconnect() } |
| } |
|
|
| private func run() async { |
| var retryDelay: UInt64 = 1_000_000_000 |
| var lastCameraEnabled: Bool? |
| var lastBrowserControlEnabled: Bool? |
| let defaults = UserDefaults.standard |
|
|
| while !Task.isCancelled { |
| if await MainActor.run(body: { AppStateStore.shared.isPaused }) { |
| try? await Task.sleep(nanoseconds: 1_000_000_000) |
| continue |
| } |
|
|
| let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false |
| if lastCameraEnabled == nil { |
| lastCameraEnabled = cameraEnabled |
| } else if lastCameraEnabled != cameraEnabled { |
| lastCameraEnabled = cameraEnabled |
| await self.session.disconnect() |
| try? await Task.sleep(nanoseconds: 200_000_000) |
| } |
| let browserControlEnabled = OpenClawConfigFile.browserControlEnabled() |
| if lastBrowserControlEnabled == nil { |
| lastBrowserControlEnabled = browserControlEnabled |
| } else if lastBrowserControlEnabled != browserControlEnabled { |
| lastBrowserControlEnabled = browserControlEnabled |
| await self.session.disconnect() |
| try? await Task.sleep(nanoseconds: 200_000_000) |
| } |
|
|
| do { |
| let config = try await GatewayEndpointStore.shared.requireConfig() |
| let caps = self.currentCaps() |
| let commands = self.currentCommands(caps: caps) |
| let permissions = await self.currentPermissions() |
| let connectOptions = GatewayConnectOptions( |
| role: "node", |
| scopes: [], |
| caps: caps, |
| commands: commands, |
| permissions: permissions, |
| clientId: "openclaw-macos", |
| clientMode: "node", |
| clientDisplayName: InstanceIdentity.displayName) |
| let sessionBox = self.buildSessionBox(url: config.url) |
|
|
| try await self.session.connect( |
| url: config.url, |
| token: config.token, |
| bootstrapToken: nil, |
| password: config.password, |
| connectOptions: connectOptions, |
| sessionBox: sessionBox, |
| onConnected: { [weak self] in |
| guard let self else { return } |
| self.logger.info("mac node connected to gateway") |
| let mainSessionKey = await GatewayConnection.shared.mainSessionKey() |
| await self.runtime.updateMainSessionKey(mainSessionKey) |
| await self.runtime.setEventSender { [weak self] event, payload in |
| guard let self else { return } |
| await self.session.sendEvent(event: event, payloadJSON: payload) |
| } |
| }, |
| onDisconnected: { [weak self] reason in |
| guard let self else { return } |
| await self.runtime.setEventSender(nil) |
| self.logger.error("mac node disconnected: \(reason, privacy: .public)") |
| }, |
| onInvoke: { [weak self] req in |
| guard let self else { |
| return BridgeInvokeResponse( |
| id: req.id, |
| ok: false, |
| error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) |
| } |
| return await self.runtime.handleInvoke(req) |
| }) |
|
|
| retryDelay = 1_000_000_000 |
| try? await Task.sleep(nanoseconds: 1_000_000_000) |
| } catch { |
| self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)") |
| try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000)) |
| retryDelay = min(retryDelay * 2, 10_000_000_000) |
| } |
| } |
| } |
|
|
| private func currentCaps() -> [String] { |
| var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue] |
| if OpenClawConfigFile.browserControlEnabled() { |
| caps.append(OpenClawCapability.browser.rawValue) |
| } |
| if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false { |
| caps.append(OpenClawCapability.camera.rawValue) |
| } |
| let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" |
| if OpenClawLocationMode(rawValue: rawLocationMode) != .off { |
| caps.append(OpenClawCapability.location.rawValue) |
| } |
| return caps |
| } |
|
|
| private func currentPermissions() async -> [String: Bool] { |
| let statuses = await PermissionManager.status() |
| return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) }) |
| } |
|
|
| private func currentCommands(caps: [String]) -> [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, |
| MacNodeScreenCommand.record.rawValue, |
| OpenClawSystemCommand.notify.rawValue, |
| OpenClawSystemCommand.which.rawValue, |
| OpenClawSystemCommand.run.rawValue, |
| OpenClawSystemCommand.execApprovalsGet.rawValue, |
| OpenClawSystemCommand.execApprovalsSet.rawValue, |
| ] |
|
|
| let capsSet = Set(caps) |
| if capsSet.contains(OpenClawCapability.browser.rawValue) { |
| commands.append(OpenClawBrowserCommand.proxy.rawValue) |
| } |
| if capsSet.contains(OpenClawCapability.camera.rawValue) { |
| commands.append(OpenClawCameraCommand.list.rawValue) |
| commands.append(OpenClawCameraCommand.snap.rawValue) |
| commands.append(OpenClawCameraCommand.clip.rawValue) |
| } |
| if capsSet.contains(OpenClawCapability.location.rawValue) { |
| commands.append(OpenClawLocationCommand.get.rawValue) |
| } |
|
|
| return commands |
| } |
|
|
| private func buildSessionBox(url: URL) -> WebSocketSessionBox? { |
| guard url.scheme?.lowercased() == "wss" else { return nil } |
| let host = url.host ?? "gateway" |
| let port = url.port ?? 443 |
| let stableID = "\(host):\(port)" |
| let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) |
| let params = GatewayTLSParams( |
| required: true, |
| expectedFingerprint: stored, |
| allowTOFU: stored == nil, |
| storeKey: stableID) |
| let session = GatewayTLSPinningSession(params: params) |
| return WebSocketSessionBox(session: session) |
| } |
| } |
|
|