diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index dd0174b7d9bb..95981532d126 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -29843,7 +29843,7 @@ }, { "kind": "conditional-branch", - "line": 762, + "line": 767, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host)", "surface": "apple", @@ -29851,7 +29851,7 @@ }, { "kind": "conditional-branch", - "line": 762, + "line": 767, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host):\\(port)", "surface": "apple", @@ -30755,7 +30755,7 @@ }, { "kind": "conditional-branch", - "line": 115, + "line": 116, "path": "apps/macos/Sources/OpenClaw/ControlChannel.swift", "source": "degraded: \\(message)", "surface": "apple", @@ -30763,7 +30763,7 @@ }, { "kind": "conditional-branch", - "line": 362, + "line": 363, "path": "apps/macos/Sources/OpenClaw/ControlChannel.swift", "source": "unknown gateway error", "surface": "apple", @@ -31595,7 +31595,7 @@ }, { "kind": "ui-named-argument", - "line": 148, + "line": 205, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Dashboard reconnecting", "surface": "apple", @@ -31603,7 +31603,7 @@ }, { "kind": "ui-named-argument", - "line": 149, + "line": 206, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "The selected Gateway changed.", "surface": "apple", @@ -31611,7 +31611,7 @@ }, { "kind": "ui-named-argument", - "line": 150, + "line": 207, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Waiting for a fresh authenticated connection.", "surface": "apple", @@ -31619,7 +31619,7 @@ }, { "kind": "ui-named-argument", - "line": 251, + "line": 345, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Dashboard unavailable", "surface": "apple", @@ -31627,15 +31627,47 @@ }, { "kind": "ui-named-argument", - "line": 253, + "line": 347, "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", "source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.", "surface": "apple", "id": "native.apple.3ed077c15e06c140" }, + { + "kind": "ui-named-argument", + "line": 546, + "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", + "source": "Could Not Switch Gateway", + "surface": "apple", + "id": "native.apple.224e2db353f3a459" + }, + { + "kind": "ui-named-argument", + "line": 573, + "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", + "source": "Could Not Open Gateway Window", + "surface": "apple", + "id": "native.apple.213d824f2205d071" + }, + { + "kind": "ui-named-argument", + "line": 593, + "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", + "source": "Could Not Set Primary Gateway", + "surface": "apple", + "id": "native.apple.f821306f90ed7692" + }, { "kind": "conditional-branch", - "line": 881, + "line": 703, + "path": "apps/macos/Sources/OpenClaw/DashboardManager.swift", + "source": "\\(base)-\\(UUID().uuidString)", + "surface": "apple", + "id": "native.apple.11ffe8832b728acd" + }, + { + "kind": "conditional-branch", + "line": 905, "path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift", "source": "[\\(host)]", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index b97dbd034cc7..3b4c44d908d0 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -5,6 +5,11 @@ import OpenClawKit import ServiceManagement import SwiftUI +private enum RemoteTLSFingerprintUpdate { + case preserve + case replace(String?) +} + enum ExecApprovalsPolicyLoadState: Equatable { case loading case available @@ -768,63 +773,6 @@ final class AppState { } } - private static func syncedGatewayRoot( - currentRoot: [String: Any], - draft: GatewayConfigSyncDraft) -> (root: [String: Any], changed: Bool) - { - var root = currentRoot - var gateway = root["gateway"] as? [String: Any] ?? [:] - var changed = false - - let desiredMode: String? = switch draft.connectionMode { - case .local: - "local" - case .remote: - "remote" - case .unconfigured: - nil - } - - let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let desiredMode { - if currentMode != desiredMode { - gateway["mode"] = desiredMode - changed = true - } - } else if currentMode != nil { - gateway.removeValue(forKey: "mode") - changed = true - } - - if draft.connectionMode == .remote { - let remoteHost = CommandResolver.parseSSHTarget(draft.remoteTarget)?.host - let currentRemote = gateway["remote"] as? [String: Any] ?? [:] - let updated = Self.updatedRemoteGatewayConfig( - current: currentRemote, - draft: .init( - transport: draft.remoteTransport, - remoteUrl: draft.remoteUrl, - remoteHost: remoteHost, - remoteTarget: draft.remoteTarget, - remoteIdentity: draft.remoteIdentity, - remoteToken: draft.remoteToken, - remoteTokenDirty: draft.remoteTokenDirty)) - if updated.changed { - gateway["remote"] = updated.remote - changed = true - } - } - - guard changed else { return (currentRoot, false) } - - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - return (root, true) - } - func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() self.earBoostActive = true @@ -1061,6 +1009,78 @@ extension AppState { } extension AppState { + private static func syncedGatewayRoot( + currentRoot: [String: Any], + draft: GatewayConfigSyncDraft, + remoteTLSFingerprintUpdate: RemoteTLSFingerprintUpdate = .preserve) + -> (root: [String: Any], changed: Bool) + { + var root = currentRoot + var gateway = root["gateway"] as? [String: Any] ?? [:] + var changed = false + + let desiredMode: String? = switch draft.connectionMode { + case .local: + "local" + case .remote: + "remote" + case .unconfigured: + nil + } + + let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let desiredMode { + if currentMode != desiredMode { + gateway["mode"] = desiredMode + changed = true + } + } else if currentMode != nil { + gateway.removeValue(forKey: "mode") + changed = true + } + + var remote = gateway["remote"] as? [String: Any] ?? [:] + var remoteChanged = false + if draft.connectionMode == .remote { + let remoteHost = CommandResolver.parseSSHTarget(draft.remoteTarget)?.host + let updated = Self.updatedRemoteGatewayConfig( + current: remote, + draft: .init( + transport: draft.remoteTransport, + remoteUrl: draft.remoteUrl, + remoteHost: remoteHost, + remoteTarget: draft.remoteTarget, + remoteIdentity: draft.remoteIdentity, + remoteToken: draft.remoteToken, + remoteTokenDirty: draft.remoteTokenDirty)) + remote = updated.remote + remoteChanged = updated.changed + } + if case let .replace(fingerprint) = remoteTLSFingerprintUpdate { + remoteChanged = Self.updateGatewayString( + &remote, + key: "tlsFingerprint", + value: fingerprint) || remoteChanged + } + if remoteChanged { + if remote.isEmpty { + gateway.removeValue(forKey: "remote") + } else { + gateway["remote"] = remote + } + changed = true + } + + guard changed else { return (currentRoot, false) } + + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + return (root, true) + } + private func syncGatewayConfigIfNeeded() { self.advanceGatewayRoutingGeneration() guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return } @@ -1115,6 +1135,15 @@ extension AppState { @discardableResult func syncGatewayConfigNow() -> Bool { + self.syncGatewayConfigNow(remoteTLSFingerprintUpdate: .preserve) + } + + @discardableResult + func syncGatewayConfigNow(remoteTLSFingerprint: String?) -> Bool { + self.syncGatewayConfigNow(remoteTLSFingerprintUpdate: .replace(remoteTLSFingerprint)) + } + + private func syncGatewayConfigNow(remoteTLSFingerprintUpdate: RemoteTLSFingerprintUpdate) -> Bool { guard self.gatewayConfigSyncIsEnabled, !self.isInitializing else { return true } self.setGatewayConfigSyncState(.pending) @@ -1134,7 +1163,8 @@ extension AppState { // Keep app-only connection settings local to avoid overwriting remote gateway config. let synced = Self.syncedGatewayRoot( currentRoot: OpenClawConfigFile.loadDict(), - draft: draft) + draft: draft, + remoteTLSFingerprintUpdate: remoteTLSFingerprintUpdate) guard synced.changed else { self.setGatewayConfigSyncState(.current) return true diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 889764eb185a..304ebe2cecc5 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -103,6 +103,7 @@ final class ControlChannel { didSet { CanvasManager.shared.refreshDebugStatus() guard oldValue != self.state else { return } + NotificationCenter.default.post(name: .controlChannelStateDidChange, object: nil) switch self.state { case .connected: self.logger.info("control channel state -> connected") @@ -545,5 +546,6 @@ final class ControlChannel { } extension Notification.Name { + static let controlChannelStateDidChange = Notification.Name("openclaw.control-channel.state-did-change") static let controlHeartbeat = Notification.Name("openclaw.control.heartbeat") } diff --git a/apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift b/apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift new file mode 100644 index 000000000000..385d61220cbe --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift @@ -0,0 +1,174 @@ +import Foundation +import OpenClawKit + +enum DashboardGatewayTarget: Equatable, Hashable, Sendable { + case primary + case profile(String) + + init?(bridgeID: String) { + if bridgeID == "primary" { + self = .primary + return + } + guard bridgeID.hasPrefix("profile:"), bridgeID.count > "profile:".count else { return nil } + self = .profile(String(bridgeID.dropFirst("profile:".count))) + } + + var bridgeID: String { + switch self { + case .primary: + "primary" + case let .profile(profileID): + "profile:\(profileID)" + } + } +} + +enum DashboardGatewayHealth: String, Codable, Equatable, Sendable { + case ok + case error + case unknown +} + +struct DashboardGatewayEntry: Codable, Equatable, Sendable { + let id: String + let name: String + let kind: String + let isPrimary: Bool + let canPromote: Bool + let health: DashboardGatewayHealth +} + +struct DashboardGatewaySnapshot: Codable, Equatable, Sendable { + let gateways: [DashboardGatewayEntry] + let currentId: String +} + +struct MacGatewayCatalogProfile: Equatable, Sendable { + let profile: MacGatewayProfile + let canPromote: Bool +} + +enum DashboardGatewayCatalog { + static func entries( + mode: AppState.ConnectionMode, + primaryRemoteURL: URL?, + resolvedRemoteHostLabel: String?, + profiles: [MacGatewayCatalogProfile], + primaryHealth: DashboardGatewayHealth) -> [DashboardGatewayEntry] + { + let canonicalPrimaryURL = mode == .remote + ? primaryRemoteURL.flatMap { try? MacGatewayProfileStore.canonicalURL($0) } + : nil + let duplicate = canonicalPrimaryURL.flatMap { primaryURL in + profiles.first { (try? MacGatewayProfileStore.canonicalURL($0.profile.url)) == primaryURL } + } + let primaryName: String = if mode == .local { + "Local Gateway" + } else if let duplicate { + duplicate.profile.name + } else { + resolvedRemoteHostLabel?.nonEmpty ?? canonicalPrimaryURL?.host ?? "Remote Gateway" + } + let primary = DashboardGatewayEntry( + id: DashboardGatewayTarget.primary.bridgeID, + name: primaryName, + kind: mode == .local ? "local" : "remote", + isPrimary: true, + canPromote: false, + health: primaryHealth) + let saved = profiles.compactMap { item -> DashboardGatewayEntry? in + // A saved identity for the active direct route is represented by the + // primary row so one physical Gateway never appears twice. + if item.profile.id == duplicate?.profile.id { return nil } + return DashboardGatewayEntry( + id: DashboardGatewayTarget.profile(item.profile.id).bridgeID, + name: item.profile.name, + kind: "remote", + isPrimary: false, + canPromote: item.canPromote, + health: .unknown) + } + return [primary] + saved + } + + @MainActor + static func primaryHealth(for state: ControlChannel.ConnectionState) -> DashboardGatewayHealth { + // ControlChannel currently has no failed state. Keep every representable + // non-connected state neutral instead of painting a speculative error. + if case .connected = state { return .ok } + return .unknown + } + + @MainActor + static func loadEntries() async throws -> [DashboardGatewayEntry] { + let state = AppStateStore.shared + let root = OpenClawConfigFile.loadDict() + let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root) + let profiles = try await MacGatewayProfileStore.shared.catalogProfiles() + return self.entries( + mode: state.connectionMode, + primaryRemoteURL: resolution.transport == .direct ? resolution.directURL : nil, + resolvedRemoteHostLabel: GatewayConnectivityCoordinator.shared.resolvedHostLabel, + profiles: profiles, + primaryHealth: self.primaryHealth(for: ControlChannel.shared.state)) + } +} + +enum DashboardPrimaryGatewayError: LocalizedError, Equatable { + case notPromotable + + var errorDescription: String? { + "This Gateway cannot be set as primary." + } +} + +@MainActor +struct DashboardPrimaryGatewayAdapter { + let state: AppState + var endpoint: @Sendable (String) async throws -> GatewayConnection.EndpointSnapshot = { profileID in + try await MacGatewayProfileStore.shared.endpoint(profileID: profileID) + } + + var currentTLSFingerprint: @MainActor () -> String? = { + GatewayRemoteConfig.resolveTLSFingerprint(root: OpenClawConfigFile.loadDict()) + } + + var persist: @MainActor (AppState, String?) -> Bool = { + $0.syncGatewayConfigNow(remoteTLSFingerprint: $1) + } + + func apply(profileID: String) async throws { + let endpoint = try await self.endpoint(profileID) + guard let token = endpoint.config.token? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + else { + throw DashboardPrimaryGatewayError.notPromotable + } + let tlsFingerprint = endpoint.tls.flatMap { route in + route.params.expectedFingerprint ?? route.params.storeKey.flatMap { + GatewayTLSStore.loadFingerprint(stableID: $0) + } + } + let previous = ( + transport: self.state.remoteTransport, + url: self.state.remoteUrl, + token: self.state.remoteToken, + mode: self.state.connectionMode, + tlsFingerprint: self.currentTLSFingerprint()) + self.state.remoteTransport = .direct + self.state.remoteUrl = endpoint.config.url.absoluteString + // Promotion intentionally moves the saved token into gateway.remote.token, + // matching the existing Settings connection flow. + self.state.remoteToken = token + self.state.connectionMode = .remote + guard self.persist(self.state, tlsFingerprint) else { + self.state.remoteTransport = previous.transport + self.state.remoteUrl = previous.url + self.state.remoteToken = previous.token + self.state.connectionMode = previous.mode + _ = self.persist(self.state, previous.tlsFingerprint) + throw DashboardPrimaryGatewayError.notPromotable + } + } +} diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index 0f22c0c07e9b..42e871f71088 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -9,14 +9,41 @@ private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: final class DashboardManager { static let shared = DashboardManager() + private struct AuxiliaryWindowInstance { + var target: DashboardGatewayTarget + var controller: DashboardWindowController + } + + private struct WindowConfiguration { + let url: URL + let auth: DashboardWindowAuth + let tlsParams: GatewayTLSParams? + let mode: AppState.ConnectionMode + let displayName: String + } + private var controller: DashboardWindowController? + private var mainTarget = DashboardGatewayTarget.primary + private var auxiliaryWindows: [UUID: AuxiliaryWindowInstance] = [:] + private var auxiliaryWindowOrder: [UUID] = [] private var endpointTask: Task? private var pendingOpenCommands: [DashboardNativeCommand] = [] private var openForCommandTask: Task? private var updater: UpdaterProviding? private var displayedRouteRevision: UInt64? + private var switchGenerations: [ObjectIdentifier: UInt64] = [:] private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String? private let routeProbe: @Sendable () async -> Void + private let mainWindowAutosaveName: String + private var gatewayEntries: [DashboardGatewayEntry] = [] + private var gatewayRefreshObservers: [NSObjectProtocol] = [] + #if DEBUG + private var testPrimaryEndpointProvider: + (@Sendable (AppState.ConnectionMode) async throws -> GatewayConnection.EndpointSnapshot)? + private var testProfileEndpointProvider: (@Sendable (String) async throws + -> GatewayConnection.EndpointSnapshot)? + private var testGatewayEntriesProvider: (@MainActor () async throws -> [DashboardGatewayEntry])? + #endif private static let failureURL = URL(string: "about:blank")! private init( @@ -29,10 +56,29 @@ final class DashboardManager { params: nil, timeoutMs: 3000, retryTransportFailures: false) - }) + }, + observeGatewayChanges: Bool = true, + mainWindowAutosaveName: String = DashboardWindowLayout.windowFrameAutosaveName) { self.authTokenProvider = authTokenProvider self.routeProbe = routeProbe + self.mainWindowAutosaveName = mainWindowAutosaveName + if observeGatewayChanges { + let names: [Notification.Name] = [ + MacGatewayProfileStore.didChangeNotification, + .openclawConfigDidChange, + .controlChannelStateDidChange, + ] + self.gatewayRefreshObservers = names.map { name in + NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: .main) + { [weak self] _ in + Task { @MainActor [weak self] in await self?.refreshGatewaySnapshots() } + } + } + } } func configure(updater: UpdaterProviding) { @@ -68,6 +114,9 @@ final class DashboardManager { } func handleEndpointState(_ state: GatewayEndpointState) async { + // The shared endpoint stream owns only the main window's primary route. + // Profile-targeted documents keep their saved endpoint and credentials. + guard self.mainTarget == .primary else { return } guard let controller, controller.isWindowOpen else { return } guard case let .ready(mode, url, token, password, routeRevision) = state else { self.replaceWithRouteFailure(controller) @@ -75,8 +124,9 @@ final class DashboardManager { return } let config: GatewayConnection.Config = (url, token, password) + let tlsParams = Self.primaryTLSParams(for: config, mode: mode) let routeChanged = self.displayedRouteRevision.map { $0 != routeRevision } - ?? (routeRevision > 0) + ?? (routeRevision > 0) || !controller.hasTLSParams(tlsParams) var authToken = await self.authTokenProvider(config) if authToken == nil, password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty == nil { await self.routeProbe() @@ -103,7 +153,8 @@ final class DashboardManager { controller, url: dashboardURL, auth: auth, - mode: mode) + mode: mode, + tlsParams: tlsParams) return } if dashboardURL == controller.currentURL { @@ -122,27 +173,33 @@ final class DashboardManager { _ current: DashboardWindowController, url: URL, auth: DashboardWindowAuth, - mode: AppState.ConnectionMode) + mode: AppState.ConnectionMode, + tlsParams: GatewayTLSParams?) { + self.switchGenerations[ObjectIdentifier(current)] = nil current.releaseFrameAutosaveForReplacement() current.closeDashboard() let replacement = DashboardWindowController( url: url, auth: auth, updater: self.updater, - updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode), + tlsParams: tlsParams, + gatewaySnapshot: self.snapshot(for: .primary)) self.controller = replacement replacement.show(url: url, auth: auth) } private func replaceWithRouteFailure(_ current: DashboardWindowController) { + self.switchGenerations[ObjectIdentifier(current)] = nil current.releaseFrameAutosaveForReplacement() current.closeDashboard() let replacement = DashboardWindowController( url: Self.failureURL, auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil), updater: self.updater, - updateBridgeEnabled: false) + updateBridgeEnabled: false, + gatewaySnapshot: self.snapshot(for: .primary)) self.controller = replacement replacement.showFailure( title: "Dashboard reconnecting", @@ -152,15 +209,17 @@ final class DashboardManager { @discardableResult func showConfiguredWindowIfPossible() -> Bool { + guard self.mainTarget == .primary else { return false } let mode = AppStateStore.shared.connectionMode - guard let config = self.immediateDashboardConfig(mode: mode), + guard let endpoint = Self.immediateDashboardEndpoint(mode: mode), let url = try? GatewayEndpointStore.dashboardURL( - for: config, + for: endpoint.config, mode: mode, - authToken: config.token) + authToken: endpoint.config.token) else { return false } + let config = endpoint.config let auth = DashboardWindowAuth( gatewayUrl: Self.websocketURLString(for: url), token: config.token, @@ -168,18 +227,27 @@ final class DashboardManager { guard auth.hasCredential else { return false } - if let controller { + if let controller, !controller.hasTLSParams(endpoint.tls?.params) { + self.replaceController( + controller, + url: url, + auth: auth, + mode: mode, + tlsParams: endpoint.tls?.params) + } else if let controller { controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) } else { let controller = DashboardWindowController( url: url, auth: auth, updater: self.updater, - updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode), + tlsParams: endpoint.tls?.params) self.controller = controller controller.show(url: url, auth: auth) } self.observeEndpointChanges() + Task { await self.refreshGatewaySnapshots() } Task { _ = try? await ControlChannel.shared.health(timeout: 3) } return true } @@ -191,21 +259,33 @@ final class DashboardManager { func preloadIfConfigured() { guard self.controller == nil, AppStateStore.shared.onboardingSeen, - let (mode, url, auth) = self.immediateWindowConfiguration() + let (mode, url, auth, tlsParams) = self.immediateWindowConfiguration() else { return } let controller = DashboardWindowController( url: url, auth: auth, updater: self.updater, - updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode), + tlsParams: tlsParams, + gatewaySnapshot: self.snapshot(for: .primary)) self.controller = controller controller.loadInBackground(url: url, auth: auth) } func show() async throws { + if let controller, self.mainTarget != .primary { + if controller.isWindowOpen { + controller.show() + await self.refreshGatewaySnapshots() + return + } + await self.switchTarget(self.mainTarget, in: controller, forceReload: true, present: true) + return + } let mode = AppStateStore.shared.connectionMode dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)") - let config = try await self.dashboardConfig(mode: mode) + let endpoint = try await self.primaryEndpoint(mode: mode) + let config = endpoint.config dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)") let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config) let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token) @@ -214,10 +294,21 @@ final class DashboardManager { token: token, password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) - if let controller { + if let controller, !controller.hasTLSParams(endpoint.tls?.params) { + self.replaceController( + controller, + url: url, + auth: auth, + mode: mode, + tlsParams: endpoint.tls?.params) + self.observeEndpointChanges() + await self.refreshGatewaySnapshots() + return + } else if let controller { dashboardManagerLogger.info("dashboard reuse window url=\(dashboardLogString(for: url), privacy: .public)") controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) self.observeEndpointChanges() + await self.refreshGatewaySnapshots() return } @@ -226,10 +317,13 @@ final class DashboardManager { url: url, auth: auth, updater: self.updater, - updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode), + tlsParams: endpoint.tls?.params, + gatewaySnapshot: self.snapshot(for: .primary)) self.controller = controller controller.show(url: url, auth: auth) self.observeEndpointChanges() + await self.refreshGatewaySnapshots() // Refresh the cached hello payload without blocking window creation. Task { _ = try? await ControlChannel.shared.health(timeout: 3) } @@ -254,7 +348,14 @@ final class DashboardManager { } func close() { + self.switchGenerations.removeAll() self.controller?.closeDashboard() + let controllers = self.auxiliaryWindows.values.map(\.controller) + self.auxiliaryWindows.removeAll() + self.auxiliaryWindowOrder.removeAll() + for controller in controllers { + controller.closeDashboard() + } } func handleOnboardingCompletion() { @@ -271,6 +372,19 @@ final class DashboardManager { self.controller?.navigateForward() } + func handleGatewayRequest(_ request: DashboardGatewaysRequest, from source: DashboardWindowController) { + switch request { + case let .select(target): + Task { await self.switchTarget(target, in: source) } + case let .openWindow(target): + Task { await self.openWindow(for: target) } + case let .setPrimary(target): + self.confirmSetPrimary(target, from: source) + case .openSettings: + AppNavigationActions.openSettings(tab: .gateways) + } + } + func dispatchNativeCommand(_ command: DashboardNativeCommand) { NSApp.activate(ignoringOtherApps: true) if let controller, controller.isWindowOpen, controller.canDeliverNativeCommands { @@ -302,6 +416,300 @@ final class DashboardManager { } } + private func snapshot(for target: DashboardGatewayTarget) -> DashboardGatewaySnapshot? { + guard !self.gatewayEntries.isEmpty else { return nil } + return DashboardGatewaySnapshot(gateways: self.gatewayEntries, currentId: target.bridgeID) + } + + private func refreshGatewaySnapshots() async { + guard let entries = try? await self.loadGatewayEntries() else { return } + self.gatewayEntries = entries + if let controller, !Self.targetIsAvailable(self.mainTarget, in: entries) { + await self.switchTarget(.primary, in: controller) + return + } + if let invalid = self.auxiliaryWindows.values.first(where: { + !Self.targetIsAvailable($0.target, in: entries) + }) { + await self.switchTarget(.primary, in: invalid.controller) + return + } + if let controller, let snapshot = self.snapshot(for: self.mainTarget) { + controller.updateGatewaySnapshot(snapshot) + } + for instance in self.auxiliaryWindows.values { + if let snapshot = self.snapshot(for: instance.target) { + instance.controller.updateGatewaySnapshot(snapshot) + } + } + } + + private func target(for source: DashboardWindowController) -> DashboardGatewayTarget? { + if self.controller === source { return self.mainTarget } + return self.auxiliaryWindows.values.first { $0.controller === source }?.target + } + + private static func targetIsAvailable( + _ target: DashboardGatewayTarget, + in entries: [DashboardGatewayEntry]) -> Bool + { + entries.contains { $0.id == target.bridgeID } + } + + private func beginSwitch(for source: DashboardWindowController) -> UInt64 { + let key = ObjectIdentifier(source) + let generation = (self.switchGenerations[key] ?? 0) &+ 1 + self.switchGenerations[key] = generation + return generation + } + + private func switchIsCurrent(_ generation: UInt64, for source: DashboardWindowController) -> Bool { + self.switchGenerations[ObjectIdentifier(source)] == generation && self.target(for: source) != nil + } + + private func finishSwitch(_ generation: UInt64, for source: DashboardWindowController) { + let key = ObjectIdentifier(source) + if self.switchGenerations[key] == generation { + self.switchGenerations[key] = nil + } + } + + private func switchTarget( + _ target: DashboardGatewayTarget, + in source: DashboardWindowController, + forceReload: Bool = false, + present: Bool? = nil) async + { + guard let currentTarget = self.target(for: source) else { return } + // Each source window has its own generation so the latest selection wins + // without serializing unrelated dashboard windows. + let generation = self.beginSwitch(for: source) + guard forceReload || currentTarget != target else { + self.finishSwitch(generation, for: source) + return + } + do { + let configuration = try await self.windowConfiguration(for: target) + guard self.switchIsCurrent(generation, for: source), self.target(for: source) == currentTarget else { + return + } + // Background reconciliation must not resurrect a window the user closed; + // explicit show/open callers opt back into presentation. + let shouldPresent = present ?? source.isWindowOpen + if self.controller === source { + let frame = source.window?.frame + if self.mainTarget == .primary, target != .primary { + self.displayedRouteRevision = nil + } + source.releaseFrameAutosaveForReplacement() + source.closeDashboard() + self.mainTarget = target + let replacement = self.makeController( + configuration: configuration, + target: target, + windowAutosaveName: self.availableAutosaveName(for: target, replacing: source), + auxiliary: false) + // In-place switches preserve the frame the user is viewing; + // target autosaves seed only newly opened windows. + if let frame { replacement.window?.setFrame(frame, display: false) } + self.controller = replacement + if shouldPresent { + replacement.show(url: configuration.url, auth: configuration.auth) + } else { + replacement.loadInBackground(url: configuration.url, auth: configuration.auth) + } + } else if let windowID = self.auxiliaryWindows.first(where: { $0.value.controller === source })?.key { + let frame = source.window?.frame + let autosaveName = self.availableAutosaveName(for: target, replacing: source) + source.onClosed = nil + source.releaseFrameAutosaveForReplacement() + source.closeDashboard() + let replacement = self.makeController( + configuration: configuration, + target: target, + windowAutosaveName: autosaveName, + auxiliary: true) + if let frame { replacement.window?.setFrame(frame, display: false) } + self.installAuxiliaryWindowCloseHandler(replacement, windowID: windowID) + self.auxiliaryWindows[windowID] = AuxiliaryWindowInstance(target: target, controller: replacement) + if shouldPresent { + replacement.show(url: configuration.url, auth: configuration.auth) + } else { + replacement.loadInBackground(url: configuration.url, auth: configuration.auth) + } + } + self.finishSwitch(generation, for: source) + await self.refreshGatewaySnapshots() + } catch { + guard self.switchIsCurrent(generation, for: source) else { return } + self.finishSwitch(generation, for: source) + Self.showGatewayError(error, message: "Could Not Switch Gateway") + } + } + + private func openWindow(for target: DashboardGatewayTarget) async { + do { + let configuration = try await self.windowConfiguration(for: target) + let windowID = UUID() + let previous = self.auxiliaryWindowOrder.reversed().lazy + .compactMap { self.auxiliaryWindows[$0] } + .first { $0.target == target }? + .controller + let controller = self.makeController( + configuration: configuration, + target: target, + windowAutosaveName: self.availableAutosaveName(for: target), + auxiliary: true) + self.installAuxiliaryWindowCloseHandler(controller, windowID: windowID) + self.auxiliaryWindows[windowID] = AuxiliaryWindowInstance(target: target, controller: controller) + self.auxiliaryWindowOrder.append(windowID) + if let previous { + let origin = previous.window?.frame.origin ?? .zero + controller.window?.setFrameOrigin(NSPoint(x: origin.x + 24, y: origin.y - 24)) + } + controller.show(url: configuration.url, auth: configuration.auth) + await self.refreshGatewaySnapshots() + } catch { + Self.showGatewayError(error, message: "Could Not Open Gateway Window") + } + } + + private func confirmSetPrimary(_ target: DashboardGatewayTarget, from source: DashboardWindowController) { + guard case let .profile(profileID) = target, + self.target(for: source) == target, + let entry = self.gatewayEntries.first(where: { $0.id == target.bridgeID }), + entry.canPromote + else { + return + } + let alert = DashboardWindowController.makeSetPrimaryAlert(gatewayName: entry.name) + let apply: (NSApplication.ModalResponse) -> Void = { [weak self, weak source] response in + guard response == .alertFirstButtonReturn, let self, let source else { return } + Task { @MainActor in + do { + try await DashboardPrimaryGatewayAdapter(state: AppStateStore.shared).apply(profileID: profileID) + await self.switchTarget(.primary, in: source) + } catch { + Self.showGatewayError(error, message: "Could Not Set Primary Gateway") + } + } + } + if let window = source.window { + alert.beginSheetModal(for: window, completionHandler: apply) + } else { + apply(alert.runModal()) + } + } + + private func windowConfiguration(for target: DashboardGatewayTarget) async throws -> WindowConfiguration { + switch target { + case .primary: + let mode = AppStateStore.shared.connectionMode + let endpoint = try await self.primaryEndpoint(mode: mode) + let config = endpoint.config + let token = await self.authTokenProvider(config) + let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token) + let auth = DashboardWindowAuth( + gatewayUrl: Self.websocketURLString(for: url), + token: token, + password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + return WindowConfiguration( + url: url, + auth: auth, + tlsParams: endpoint.tls?.params, + mode: mode, + displayName: "OpenClaw") + case let .profile(profileID): + let endpoint = try await self.profileEndpoint(profileID: profileID) + let url = try GatewayEndpointStore.dashboardURL( + for: endpoint.config, + mode: .remote, + authToken: endpoint.config.token) + let auth = DashboardWindowAuth( + gatewayUrl: Self.websocketURLString(for: url), + token: endpoint.config.token, + password: endpoint.config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + let name = self.gatewayEntries.first { $0.id == target.bridgeID }?.name ?? url.host ?? "Gateway" + return WindowConfiguration( + url: url, + auth: auth, + tlsParams: endpoint.tls?.params, + mode: .remote, + displayName: name) + } + } + + private func makeController( + configuration: WindowConfiguration, + target: DashboardGatewayTarget, + windowAutosaveName: String, + auxiliary: Bool) -> DashboardWindowController + { + let primaryLocal = !auxiliary && target == .primary && configuration.mode == .local + if primaryLocal { + return DashboardWindowController( + url: configuration.url, + auth: configuration.auth, + updater: self.updater, + updateBridgeEnabled: Self.updateBridgeEnabled(mode: configuration.mode), + tlsParams: configuration.tlsParams, + gatewaySnapshot: self.snapshot(for: target), + windowTitle: configuration.displayName, + windowAutosaveName: windowAutosaveName) + } + return DashboardWindowController( + url: configuration.url, + auth: configuration.auth, + updater: self.updater, + updateBridgeEnabled: false, + tlsParams: configuration.tlsParams, + gatewaySnapshot: self.snapshot(for: target), + windowTitle: configuration.displayName, + windowAutosaveName: windowAutosaveName, + requestBrowserProfileImportOffer: { _ in false }) + } + + private func installAuxiliaryWindowCloseHandler(_ controller: DashboardWindowController, windowID: UUID) { + controller.onClosed = { [weak self, weak controller] in + guard let self, let controller, + self.auxiliaryWindows[windowID]?.controller === controller + else { + return + } + self.switchGenerations[ObjectIdentifier(controller)] = nil + self.auxiliaryWindows.removeValue(forKey: windowID) + self.auxiliaryWindowOrder.removeAll { $0 == windowID } + } + } + + private func autosaveName(for target: DashboardGatewayTarget) -> String { + switch target { + case .primary: + self.mainWindowAutosaveName + case let .profile(profileID): + "OpenClawDashboardWindow-\(profileID)" + } + } + + private func availableAutosaveName( + for target: DashboardGatewayTarget, + replacing source: DashboardWindowController? = nil) -> String + { + let base = self.autosaveName(for: target) + let mainOwnsTarget = self.controller !== source && self.mainTarget == target + let auxiliaryOwnsTarget = self.auxiliaryWindows.values.contains { + $0.controller !== source && $0.target == target + } + return mainOwnsTarget || auxiliaryOwnsTarget ? "\(base)-\(UUID().uuidString)" : base + } + + private static func showGatewayError(_ error: Error, message: String) { + let alert = NSAlert() + alert.messageText = message + alert.informativeText = error.localizedDescription + alert.runModal() + } + private static func websocketURLString(for dashboardURL: URL) -> String { guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else { return dashboardURL.absoluteString @@ -317,52 +725,111 @@ final class DashboardManager { return components.url?.absoluteString ?? dashboardURL.absoluteString } - private func dashboardConfig(mode: AppState.ConnectionMode) async throws -> GatewayConnection.Config { - if let config = self.immediateDashboardConfig(mode: mode) { - return config + private func primaryEndpoint( + mode: AppState.ConnectionMode) async throws -> GatewayConnection.EndpointSnapshot + { + #if DEBUG + if let testPrimaryEndpointProvider { + return try await testPrimaryEndpointProvider(mode) } + #endif + return try await Self.resolvePrimaryEndpoint(mode: mode) + } + private func profileEndpoint(profileID: String) async throws -> GatewayConnection.EndpointSnapshot { + #if DEBUG + if let testProfileEndpointProvider { + return try await testProfileEndpointProvider(profileID) + } + #endif + return try await MacGatewayProfileStore.shared.endpoint(profileID: profileID) + } + + private func loadGatewayEntries() async throws -> [DashboardGatewayEntry] { + #if DEBUG + if let testGatewayEntriesProvider { + return try await testGatewayEntriesProvider() + } + #endif + return try await DashboardGatewayCatalog.loadEntries() + } + + private static func resolvePrimaryEndpoint( + mode: AppState.ConnectionMode) async throws -> GatewayConnection.EndpointSnapshot + { + if let endpoint = self.immediateDashboardEndpoint(mode: mode) { + return endpoint + } return try await Task.detached(priority: .userInitiated) { await GatewayEndpointStore.shared.refresh() - return try await GatewayEndpointStore.shared.requireConfig() + return try await GatewayEndpointStore.shared.requireEndpoint() }.value } - private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? { + private static func immediateDashboardEndpoint( + mode: AppState.ConnectionMode) -> GatewayConnection.EndpointSnapshot? + { let root = OpenClawConfigFile.loadDict() let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root) if mode == .remote, resolution.transport == .direct, let url = resolution.directURL { - return ( - url, - GatewayRemoteConfig.resolveTokenString(root: root), - GatewayRemoteConfig.resolvePasswordString(root: root)) + return GatewayConnection.EndpointSnapshot( + config: ( + url, + GatewayRemoteConfig.resolveTokenString(root: root), + GatewayRemoteConfig.resolvePasswordString(root: root)), + tls: GatewayTLSRoute.resolve( + url: url, + connectionMode: mode, + configuredFingerprint: GatewayRemoteConfig.resolveTLSFingerprint(root: root)), + routeAuthority: nil) } if mode == .local { - return GatewayEndpointStore.localConfig() + let config = GatewayEndpointStore.localConfig() + return GatewayConnection.EndpointSnapshot( + config: config, + tls: GatewayTLSRoute.resolve( + url: config.url, + connectionMode: mode, + configuredFingerprint: nil), + routeAuthority: nil) } return nil } + private static func primaryTLSParams( + for config: GatewayConnection.Config, + mode: AppState.ConnectionMode) -> GatewayTLSParams? + { + let root = OpenClawConfigFile.loadDict() + return GatewayTLSRoute.resolve( + url: config.url, + connectionMode: mode, + configuredFingerprint: mode == .remote + ? GatewayRemoteConfig.resolveTLSFingerprint(root: root) + : nil)?.params + } + private func immediateWindowConfiguration() - -> (AppState.ConnectionMode, URL, DashboardWindowAuth)? + -> (AppState.ConnectionMode, URL, DashboardWindowAuth, GatewayTLSParams?)? { let mode = AppStateStore.shared.connectionMode - guard let config = self.immediateDashboardConfig(mode: mode), + guard let endpoint = Self.immediateDashboardEndpoint(mode: mode), let url = try? GatewayEndpointStore.dashboardURL( - for: config, + for: endpoint.config, mode: mode, - authToken: config.token) + authToken: endpoint.config.token) else { return nil } + let config = endpoint.config let auth = DashboardWindowAuth( gatewayUrl: Self.websocketURLString(for: url), token: config.token, password: (config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)) - return auth.hasCredential ? (mode, url, auth) : nil + return auth.hasCredential ? (mode, url, auth, endpoint.tls?.params) : nil } } @@ -372,11 +839,23 @@ extension DashboardManager { /// store cannot race test-driven `handleEndpointState` calls. static func _testMake( authTokenProvider: @escaping @Sendable (GatewayConnection.Config) async -> String? = { $0.token }, - routeProbe: @escaping @Sendable () async -> Void = {}) -> DashboardManager + routeProbe: @escaping @Sendable () async -> Void = {}, + primaryEndpointProvider: (@Sendable (AppState.ConnectionMode) async throws + -> GatewayConnection.EndpointSnapshot)? = nil, + profileEndpointProvider: (@Sendable (String) async throws + -> GatewayConnection.EndpointSnapshot)? = nil, + gatewayEntriesProvider: (@MainActor () async throws -> [DashboardGatewayEntry])? = nil) + -> DashboardManager { - DashboardManager( + let manager = DashboardManager( authTokenProvider: authTokenProvider, - routeProbe: routeProbe) + routeProbe: routeProbe, + observeGatewayChanges: false, + mainWindowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") + manager.testPrimaryEndpointProvider = primaryEndpointProvider + manager.testProfileEndpointProvider = profileEndpointProvider + manager.testGatewayEntriesProvider = gatewayEntriesProvider + return manager } func _testSetController(_ controller: DashboardWindowController?) { @@ -386,5 +865,48 @@ extension DashboardManager { func _testController() -> DashboardWindowController? { self.controller } + + func _testMainTarget() -> DashboardGatewayTarget { + self.mainTarget + } + + func _testOpenWindow(for target: DashboardGatewayTarget) async { + await self.openWindow(for: target) + } + + func _testSwitchTarget(_ target: DashboardGatewayTarget, in source: DashboardWindowController) async { + await self.switchTarget(target, in: source) + } + + func _testWindowTLSParams(for target: DashboardGatewayTarget) async throws -> GatewayTLSParams? { + try await self.windowConfiguration(for: target).tlsParams + } + + func _testAuxiliaryWindows() -> [(target: DashboardGatewayTarget, controller: DashboardWindowController)] { + self.auxiliaryWindows.values.map { ($0.target, $0.controller) } + } + + func _testSetMainTarget(_ target: DashboardGatewayTarget) { + self.mainTarget = target + if target != .primary { + self.displayedRouteRevision = nil + } + } + + static func _testAutosaveName(for target: DashboardGatewayTarget) -> String { + switch target { + case .primary: + DashboardWindowLayout.windowFrameAutosaveName + case let .profile(profileID): + "OpenClawDashboardWindow-\(profileID)" + } + } + + static func _testTargetIsAvailable( + _ target: DashboardGatewayTarget, + in entries: [DashboardGatewayEntry]) -> Bool + { + self.targetIsAvailable(target, in: entries) + } } #endif diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift new file mode 100644 index 000000000000..1fa7d7a07437 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController+Gateways.swift @@ -0,0 +1,154 @@ +import AppKit +import Foundation +import OpenClawKit +import WebKit + +enum DashboardGatewaysRequest: Equatable { + case select(DashboardGatewayTarget) + case openWindow(DashboardGatewayTarget) + case setPrimary(DashboardGatewayTarget) + case openSettings +} + +@MainActor +final class DashboardGatewaysMessageHandler: NSObject, WKScriptMessageHandler { + weak var owner: DashboardWindowController? + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + self.owner?.receiveGatewaysMessage(message) + } +} + +extension DashboardWindowController { + static let gatewaysMessageHandlerName = "openclawGateways" + + func hasTLSParams(_ params: GatewayTLSParams?) -> Bool { + self.tlsParams == params + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @MainActor @Sendable ( + URLSession.AuthChallengeDisposition, + URLCredential?) -> Void) + { + guard webView === self.webView, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust + else { + completionHandler(.performDefaultHandling, nil) + return + } + guard let params = self.tlsParams else { + completionHandler(.performDefaultHandling, nil) + return + } + guard Self.isExpectedTLSAuthority( + host: challenge.protectionSpace.host, + port: challenge.protectionSpace.port, + dashboardURL: self.currentURL) + else { + completionHandler(.performDefaultHandling, nil) + return + } + guard let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + switch GatewayTLSServerTrust.evaluate( + trust: trust, + host: challenge.protectionSpace.host, + port: challenge.protectionSpace.port, + params: params) + { + case .accept: + completionHandler(.useCredential, URLCredential(trust: trust)) + case .reject: + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + + static func isExpectedTLSAuthority(host: String, port: Int, dashboardURL: URL) -> Bool { + let expectedHost = dashboardURL.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let challengedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let expectedPort = dashboardURL.port ?? (dashboardURL.scheme?.lowercased() == "https" ? 443 : 80) + return expectedHost?.isEmpty == false && challengedHost == expectedHost && port == expectedPort + } + + static func gatewaysRequest(from body: Any) -> DashboardGatewaysRequest? { + guard let payload = body as? [String: Any], let type = payload["type"] as? String else { + return nil + } + if type == "open-settings" { return .openSettings } + guard let id = payload["id"] as? String, + let target = DashboardGatewayTarget(bridgeID: id) + else { + return nil + } + return switch type { + case "select": .select(target) + case "open-window": .openWindow(target) + case "set-primary": .setPrimary(target) + default: nil + } + } + + func receiveGatewaysMessage(_ message: WKScriptMessage) { + guard message.name == Self.gatewaysMessageHandlerName, + message.webView === self.webView, + message.frameInfo.isMainFrame, + Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL), + let request = Self.gatewaysRequest(from: message.body) + else { + return + } + DashboardManager.shared.handleGatewayRequest(request, from: self) + } + + func updateGatewaySnapshot(_ snapshot: DashboardGatewaySnapshot) { + self.gatewaySnapshot = snapshot + let controller = self.webView.configuration.userContentController + controller.removeAllUserScripts() + Self.installNativeChromeScript(into: controller) + Self.installNativeGatewaysScript(into: controller, snapshot: snapshot) + Self.installNativeAuthScript(into: controller, url: self.currentURL, auth: self.auth) + self.webView.evaluateJavaScript(Self.nativeGatewaysScriptSource(snapshot: snapshot, dispatch: true)) + } + + static func installNativeGatewaysScript( + into userContentController: WKUserContentController, + snapshot: DashboardGatewaySnapshot?) + { + guard let snapshot else { return } + userContentController.addUserScript(WKUserScript( + source: self.nativeGatewaysScriptSource(snapshot: snapshot, dispatch: false), + injectionTime: .atDocumentStart, + forMainFrameOnly: true)) + } + + static func nativeGatewaysScriptSource( + snapshot: DashboardGatewaySnapshot, + dispatch: Bool) -> String + { + guard let data = try? JSONEncoder().encode(snapshot), + let json = String(data: data, encoding: .utf8) + else { + return "" + } + let event = dispatch + ? "window.dispatchEvent(new CustomEvent('openclaw:native-gateways-changed'," + + "{detail:window.__OPENCLAW_NATIVE_GATEWAYS__}));" + : "" + return "window.__OPENCLAW_NATIVE_GATEWAYS__=\(json);\(event)" + } + + static func makeSetPrimaryAlert(gatewayName: String) -> NSAlert { + let alert = NSAlert() + alert.messageText = "Set \(gatewayName) as primary?" + alert.informativeText = + "This changes the Mac app's primary Gateway and resets Talk Mode, canvas, and chat connections." + alert.addButton(withTitle: "Set as Primary") + alert.addButton(withTitle: "Cancel") + return alert + } +} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 1be3c74bcf2b..c65cbee1a95c 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import OpenClawKit import WebKit private final class DashboardWindowContentView: NSView { @@ -94,7 +95,10 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, private let splitViewController: NSSplitViewController private let updateMessageHandler: DashboardUpdateMessageHandler private(set) var currentURL: URL - private var auth: DashboardWindowAuth + var auth: DashboardWindowAuth + var gatewaySnapshot: DashboardGatewaySnapshot? + let tlsParams: GatewayTLSParams? + private let dashboardFrameAutosaveName: String private let updater: UpdaterProviding? private var updateBridgeEnabled: Bool private let requestBrowserProfileImportOffer: @@ -108,12 +112,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, private var hasLiveContent = false private var isShowingFailurePage = false private var pendingNativeCommands: [DashboardNativeCommand] = [] + var onClosed: (() -> Void)? init( url: URL, auth: DashboardWindowAuth, updater: UpdaterProviding? = nil, updateBridgeEnabled: Bool = true, + tlsParams: GatewayTLSParams? = nil, + gatewaySnapshot: DashboardGatewaySnapshot? = nil, + windowTitle: String = "OpenClaw", + windowAutosaveName: String = DashboardWindowLayout.windowFrameAutosaveName, requestBrowserProfileImportOffer: @escaping @MainActor (@escaping @MainActor () -> Bool) async -> Bool = { shouldApply in await BrowserProfileImportModel.shared.requestAutomaticOfferIfEligible(while: shouldApply) @@ -122,6 +131,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, let shouldEnableUpdateBridge = updater?.isAvailable == true && updateBridgeEnabled self.currentURL = url self.auth = auth + self.gatewaySnapshot = gatewaySnapshot + self.tlsParams = tlsParams + self.dashboardFrameAutosaveName = windowAutosaveName self.updater = updater self.updateBridgeEnabled = shouldEnableUpdateBridge self.requestBrowserProfileImportOffer = requestBrowserProfileImportOffer @@ -139,6 +151,8 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, config.userContentController.add(windowDragMessageHandler, name: Self.windowDragMessageHandlerName) let notificationsMessageHandler = DashboardNotificationsMessageHandler() config.userContentController.add(notificationsMessageHandler, name: Self.notificationsMessageHandlerName) + let gatewaysMessageHandler = DashboardGatewaysMessageHandler() + config.userContentController.add(gatewaysMessageHandler, name: Self.gatewaysMessageHandlerName) let updateMessageHandler = DashboardUpdateMessageHandler() self.updateMessageHandler = updateMessageHandler if shouldEnableUpdateBridge { @@ -147,6 +161,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, config.userContentController.add(updateMessageHandler, name: Self.updateMessageHandlerName) } Self.installNativeChromeScript(into: config.userContentController) + Self.installNativeGatewaysScript(into: config.userContentController, snapshot: gatewaySnapshot) Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth) self.webView = DashboardWebView( @@ -189,12 +204,15 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.linkBrowserSplitView = linkBrowserSplitView self.splitViewController = splitViewController - let window = Self.makeWindow(contentView: splitViewController.view) + let window = Self.makeWindow( + contentView: splitViewController.view, + title: windowTitle, + frameAutosaveName: windowAutosaveName) super.init(window: window) // NSWindowController adopts its own frame state during initialization; // keep it aligned with the autosave name installed by makeWindow, then // re-correct placement in case the assignment re-applied a stale frame. - self.windowFrameAutosaveName = DashboardWindowLayout.windowFrameAutosaveName + self.windowFrameAutosaveName = windowAutosaveName WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize) // Width is autosaved, while each new dashboard window starts with the @@ -203,6 +221,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, linkMessageHandler.owner = self windowDragMessageHandler.owner = self notificationsMessageHandler.owner = self + gatewaysMessageHandler.owner = self updateMessageHandler.owner = self self.webView.navigationDelegate = self self.webView.uiDelegate = self @@ -414,7 +433,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, func releaseFrameAutosaveForReplacement() { // AppKit rejects duplicate autosave owners. Release only when the manager // replaces this controller so the successor can restore the saved frame. - self.window?.saveFrame(usingName: DashboardWindowLayout.windowFrameAutosaveName) + self.window?.saveFrame(usingName: self.dashboardFrameAutosaveName) self.windowFrameAutosaveName = "" } @@ -688,6 +707,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, let controller = self.webView.configuration.userContentController controller.removeAllUserScripts() Self.installNativeChromeScript(into: controller) + Self.installNativeGatewaysScript(into: controller, snapshot: self.gatewaySnapshot) Self.installNativeAuthScript(into: controller, url: url, auth: auth) } @@ -739,7 +759,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return linkWebView } - private static func makeWindow(contentView: NSView) -> NSWindow { + private static func makeWindow( + contentView: NSView, + title: String, + frameAutosaveName: String) -> NSWindow + { let window = DashboardWindow( contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], @@ -772,7 +796,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor), topRightDragRegion.heightAnchor.constraint(equalToConstant: 6), ]) - window.title = "OpenClaw" + window.title = title window.titleVisibility = .hidden window.titlebarAppearsTransparent = true // An empty unified toolbar grows the transparent titlebar to 52pt so the @@ -793,12 +817,12 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, window.minSize = DashboardWindowLayout.windowMinSize // Autosave restore first, placement correction last: a frame saved on // a since-disconnected monitor must not leave the window off-screen. - window.setFrameAutosaveName(DashboardWindowLayout.windowFrameAutosaveName) + window.setFrameAutosaveName(frameAutosaveName) WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize) return window } - private static func installNativeChromeScript(into userContentController: WKUserContentController) { + static func installNativeChromeScript(into userContentController: WKUserContentController) { // Deliberately no native fallback for pages that ignore this flag // (older gateway bundles, failure pages): they keep their own in-page // toggles plus back/forward gestures and the Cmd-[/] menu items. @@ -840,7 +864,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) } - private static func installNativeAuthScript( + static func installNativeAuthScript( into userContentController: WKUserContentController, url: URL, auth: DashboardWindowAuth) @@ -984,6 +1008,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, func windowWillClose(_: Notification) { self.webView.stopLoading() self.closeLinkBrowser(focusDashboard: false) + self.onClosed?() } private func showLoadFailure(_ error: Error) { @@ -1256,6 +1281,10 @@ extension DashboardWindowController { self.updateBridgeEnabled } + var _testTLSParams: GatewayTLSParams? { + self.tlsParams + } + var _testLinkBrowserIsCollapsed: Bool { self.linkBrowserItem.isCollapsed } diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 1c3abb6f7bf8..044b6778ceae 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -596,10 +596,6 @@ actor GatewayEndpointStore { return port } - func requireConfig() async throws -> GatewayConnection.Config { - try await self.requireEndpoint().config - } - /// Returns endpoint credentials and tunnel authority from the same actor /// snapshot. Callers must never stitch these values together across awaits. func requireEndpoint() async throws -> GatewayConnection.EndpointSnapshot { diff --git a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift index a0abc0783c78..47189c55a253 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift @@ -37,6 +37,8 @@ enum MacGatewayProfileError: LocalizedError, Equatable { actor MacGatewayProfileStore { static let shared = MacGatewayProfileStore() + static let didChangeNotification = Notification.Name("openclaw.gateway-profiles.did-change") + struct StoredProfile: Codable, Equatable { var profile: MacGatewayProfile var credentials: Credentials @@ -106,6 +108,7 @@ actor MacGatewayProfileStore { // Metadata and secrets share one Keychain value, so the profile becomes // reachable only when the complete record commits. try self.saveRegistry(registry) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) return profile } @@ -113,6 +116,15 @@ actor MacGatewayProfileStore { try Self.sortedProfiles(self.loadRegistryMigratingLegacyPrimary().profiles.map(\.profile)) } + func catalogProfiles() throws -> [MacGatewayCatalogProfile] { + let stored = try self.loadRegistryMigratingLegacyPrimary().profiles + return Self.sortedProfiles(stored.map(\.profile)).compactMap { profile in + guard let item = stored.first(where: { $0.profile.id == profile.id }) else { return nil } + let token = item.credentials.token?.trimmingCharacters(in: .whitespacesAndNewlines) + return MacGatewayCatalogProfile(profile: profile, canPromote: token?.isEmpty == false) + } + } + func remove(profileID: String) throws { var registry = try self.loadRegistry() guard registry.profiles.contains(where: { $0.profile.id == profileID }) else { @@ -120,6 +132,7 @@ actor MacGatewayProfileStore { } registry.profiles.removeAll { $0.profile.id == profileID } try self.saveRegistry(registry) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) } func endpoint(profileID: String) throws -> GatewayConnection.EndpointSnapshot { diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift new file mode 100644 index 000000000000..6eeac3c4d004 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift @@ -0,0 +1,437 @@ +import AppKit +import Foundation +import OpenClawKit +import Testing +@testable import OpenClaw + +struct DashboardGatewayCatalogTests { + @Test func `catalog deduplicates active profile and adopts its name`() throws { + let primaryURL = try #require(URL(string: "wss://studio.example/control")) + let duplicate = MacGatewayCatalogProfile( + profile: MacGatewayProfile(id: "studio", name: "My Studio", url: primaryURL), + canPromote: true) + let other = try MacGatewayCatalogProfile( + profile: MacGatewayProfile( + id: "backup", + name: "Backup", + url: #require(URL(string: "wss://backup.example"))), + canPromote: false) + + let entries = DashboardGatewayCatalog.entries( + mode: .remote, + primaryRemoteURL: primaryURL, + resolvedRemoteHostLabel: "studio.example:443", + profiles: [duplicate, other], + primaryHealth: .ok) + + #expect(entries.map(\.id) == ["primary", "profile:backup"]) + #expect(entries[0].name == "My Studio") + #expect(entries[0].kind == "remote") + #expect(entries[0].health == .ok) + #expect(!entries[0].canPromote) + #expect(!entries[1].canPromote) + #expect(entries[1].health == .unknown) + } + + @Test @MainActor func `catalog maps only connected control state to healthy`() { + #expect(DashboardGatewayCatalog.primaryHealth(for: .connected) == .ok) + #expect(DashboardGatewayCatalog.primaryHealth(for: .disconnected) == .unknown) + #expect(DashboardGatewayCatalog.primaryHealth(for: .connecting) == .unknown) + #expect(DashboardGatewayCatalog.primaryHealth(for: .degraded("offline")) == .unknown) + } + + @Test func `local catalog does not deduplicate a retained remote profile`() throws { + let url = try #require(URL(string: "wss://studio.example")) + let entries = DashboardGatewayCatalog.entries( + mode: .local, + primaryRemoteURL: url, + resolvedRemoteHostLabel: "127.0.0.1:18789", + profiles: [.init( + profile: .init(id: "studio", name: "Studio", url: url), + canPromote: true)], + primaryHealth: .ok) + + #expect(entries.map(\.id) == ["primary", "profile:studio"]) + #expect(entries[0].name == "Local Gateway") + } +} + +@MainActor +struct DashboardGatewaysBridgeTests { + @Test func `parses gateway bridge requests with role based ids`() { + #expect(DashboardWindowController.gatewaysRequest( + from: ["type": "select", "id": "primary"]) == .select(.primary)) + #expect(DashboardWindowController.gatewaysRequest( + from: ["type": "open-window", "id": "profile:studio"]) == .openWindow(.profile("studio"))) + #expect(DashboardWindowController.gatewaysRequest( + from: ["type": "set-primary", "id": "profile:studio"]) == .setPrimary(.profile("studio"))) + #expect(DashboardWindowController.gatewaysRequest( + from: ["type": "open-settings"]) == .openSettings) + #expect(DashboardWindowController.gatewaysRequest( + from: ["type": "select", "id": "https://secret.example"]) == nil) + } + + @Test func `gateway script contains metadata and no credentials`() { + let snapshot = DashboardGatewaySnapshot( + gateways: [.init( + id: "primary", + name: "Local Gateway", + kind: "local", + isPrimary: true, + canPromote: false, + health: .ok)], + currentId: "primary") + let script = DashboardWindowController.nativeGatewaysScriptSource(snapshot: snapshot, dispatch: true) + #expect(script.contains("__OPENCLAW_NATIVE_GATEWAYS__")) + #expect(script.contains("openclaw:native-gateways-changed")) + #expect(!script.contains("token")) + #expect(!script.contains("password")) + } + + @Test func `dashboard controller retains profile TLS policy`() throws { + let url = try #require(URL(string: "https://gateway.example/control/")) + let params = GatewayTLSParams( + required: true, + expectedFingerprint: String(repeating: "a", count: 64), + allowTOFU: false, + storeKey: "profile:studio") + let controller = DashboardWindowController( + url: url, + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil), + tlsParams: params, + windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") + + #expect(controller._testTLSParams == params) + #expect(DashboardWindowController.isExpectedTLSAuthority( + host: "gateway.example", + port: 443, + dashboardURL: url)) + #expect(!DashboardWindowController.isExpectedTLSAuthority( + host: "other.example", + port: 443, + dashboardURL: url)) + } +} + +@Suite(.serialized) +@MainActor +struct DashboardManagerGatewayTargetTests { + @Test func `primary window configuration retains resolved TLS policy`() async throws { + let state = AppStateStore.shared + let originalMode = state.connectionMode + state.connectionMode = .remote + defer { state.connectionMode = originalMode } + let url = try #require(URL(string: "wss://studio.example:443/")) + let params = GatewayTLSParams( + required: true, + expectedFingerprint: String(repeating: "a", count: 64), + allowTOFU: false, + storeKey: "primary") + let manager = DashboardManager._testMake(primaryEndpointProvider: { _ in + GatewayConnection.EndpointSnapshot( + config: (url: url, token: "primary-token", password: nil), + tls: GatewayTLSRoute(params: params, allowsTrustedPinReplacement: false), + routeAuthority: nil) + }) + + #expect(try await manager._testWindowTLSParams(for: .primary) == params) + } + + @Test func `primary endpoint subscription does not mutate profile targeted main window`() async throws { + let url = try #require(URL(string: "http://127.0.0.1:60001/#token=current")) + let controller = DashboardWindowController( + url: url, + auth: DashboardWindowAuth( + gatewayUrl: "ws://127.0.0.1:60001/", + token: "current", + password: nil), + windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") + controller.show() + defer { controller.closeDashboard() } + let entries = DashboardGatewayTestEntries.withProfiles(["studio"]) + let manager = DashboardManager._testMake(gatewayEntriesProvider: { entries }) + manager._testSetController(controller) + manager._testSetMainTarget(.profile("studio")) + + try await manager.handleEndpointState(.ready( + mode: .remote, + url: #require(URL(string: "ws://127.0.0.1:60002")), + token: "replacement", + password: nil, + routeRevision: 2)) + + #expect(manager._testController() === controller) + #expect(controller.currentURL == url) + + try await manager.show() + #expect(manager._testController() === controller) + #expect(manager._testMainTarget() == .profile("studio")) + #expect(!manager.showConfiguredWindowIfPossible()) + } + + @Test func `profile dashboard autosave name is target specific`() { + #expect(DashboardManager._testAutosaveName(for: .profile("studio")) == + "OpenClawDashboardWindow-studio") + } + + @Test func `removed current profile requires target reconciliation`() { + let primary = DashboardGatewayEntry( + id: "primary", + name: "Local Gateway", + kind: "local", + isPrimary: true, + canPromote: false, + health: .ok) + + #expect(DashboardManager._testTargetIsAvailable(.primary, in: [primary])) + #expect(!DashboardManager._testTargetIsAvailable(.profile("removed"), in: [primary])) + } + + @Test func `opening primary creates isolated auxiliary window`() async throws { + let state = AppStateStore.shared + let originalMode = state.connectionMode + state.connectionMode = .local + defer { state.connectionMode = originalMode } + let url = try #require(URL(string: "http://127.0.0.1:60001/#token=current")) + let controller = DashboardWindowController( + url: url, + auth: DashboardWindowAuth( + gatewayUrl: "ws://127.0.0.1:60001/", + token: "current", + password: nil), + windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") + let frame = NSRect(x: 180, y: 180, width: 960, height: 720) + controller.window?.setFrame(frame, display: false) + controller.show() + let entries = DashboardGatewayTestEntries.withProfiles(["studio"]) + let manager = DashboardManager._testMake(gatewayEntriesProvider: { entries }) + manager.configure(updater: DashboardGatewayTestUpdater()) + manager._testSetController(controller) + manager._testSetMainTarget(.profile("studio")) + defer { manager.close() } + + await manager._testOpenWindow(for: .primary) + + #expect(manager._testController() === controller) + #expect(manager._testMainTarget() == .profile("studio")) + #expect(controller.window?.frame == frame) + let auxiliaryWindows = manager._testAuxiliaryWindows() + #expect(auxiliaryWindows.count == 1) + let auxiliary = try #require(auxiliaryWindows.first) + #expect(auxiliary.target == .primary) + #expect(auxiliary.controller !== controller) + #expect(auxiliary.controller.window?.frameAutosaveName != controller.window?.frameAutosaveName) + #expect(!auxiliary.controller._testUpdateBridgeAvailable) + } + + @Test func `concurrent switches keep the latest selection for one window`() async throws { + let gate = DashboardSwitchEndpointGate() + let sourceURL = try #require(URL(string: "http://127.0.0.1:60001/#token=current")) + let controller = DashboardWindowController( + url: sourceURL, + auth: DashboardWindowAuth( + gatewayUrl: "ws://127.0.0.1:60001/", + token: "current", + password: nil), + windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)") + let entries = DashboardGatewayTestEntries.withProfiles(["first", "second"]) + let manager = DashboardManager._testMake( + profileEndpointProvider: { profileID in + try await gate.endpoint(profileID) + }, + gatewayEntriesProvider: { entries }) + manager._testSetController(controller) + defer { manager.close() } + + let first = Task { @MainActor in + await manager._testSwitchTarget(.profile("first"), in: controller) + } + await gate.waitUntilFirstRequested() + let second = Task { @MainActor in + await manager._testSwitchTarget(.profile("second"), in: controller) + } + await second.value + await gate.releaseFirst() + await first.value + + #expect(manager._testMainTarget() == .profile("second")) + #expect(manager._testController()?.currentURL.port == 60003) + } +} + +private enum DashboardGatewayTestEntries { + static func withProfiles(_ profileIDs: [String]) -> [DashboardGatewayEntry] { + [ + DashboardGatewayEntry( + id: "primary", + name: "Local Gateway", + kind: "local", + isPrimary: true, + canPromote: false, + health: .ok), + ] + profileIDs.map { profileID in + DashboardGatewayEntry( + id: "profile:\(profileID)", + name: profileID.capitalized, + kind: "remote", + isPrimary: false, + canPromote: true, + health: .unknown) + } + } +} + +private actor DashboardSwitchEndpointGate { + private var firstRequested = false + private var firstContinuation: CheckedContinuation? + + func endpoint(_ profileID: String) async throws -> GatewayConnection.EndpointSnapshot { + if profileID == "first" { + self.firstRequested = true + await withCheckedContinuation { continuation in + self.firstContinuation = continuation + } + } + let port = profileID == "first" ? 60002 : 60003 + let url = try #require(URL(string: "ws://127.0.0.1:\(port)")) + return GatewayConnection.EndpointSnapshot( + config: (url: url, token: profileID, password: nil), + routeAuthority: nil) + } + + func waitUntilFirstRequested() async { + while !self.firstRequested { + await Task.yield() + } + } + + func releaseFirst() { + self.firstContinuation?.resume() + self.firstContinuation = nil + } +} + +@MainActor +private final class DashboardGatewayTestUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let updateStatus = UpdateStatus() + + func checkForUpdates(_: Any?) {} +} + +@MainActor +struct DashboardPrimaryGatewayAdapterTests { + @Test func `token profile promotion carries its TLS pin`() async throws { + let state = AppState(preview: true) + let url = try #require(URL(string: "wss://studio.example:443/")) + let fingerprint = String(repeating: "a", count: 64) + var persistedFingerprints: [String?] = [] + let adapter = DashboardPrimaryGatewayAdapter( + state: state, + endpoint: { _ in + GatewayConnection.EndpointSnapshot( + config: (url: url, token: "profile-token", password: nil), + tls: DashboardGatewayTestTLS.route(fingerprint: fingerprint), + routeAuthority: nil) + }, + persist: { _, fingerprint in + persistedFingerprints.append(fingerprint) + return true + }) + + try await adapter.apply(profileID: "studio") + + #expect(state.remoteTransport == .direct) + #expect(state.remoteUrl == url.absoluteString) + #expect(state.remoteToken == "profile-token") + #expect(state.connectionMode == .remote) + #expect(persistedFingerprints == [fingerprint]) + } + + @Test func `token profile without a pin clears the previous primary pin`() async throws { + let state = AppState(preview: true) + let url = try #require(URL(string: "wss://studio.example:443/")) + var persistedFingerprints: [String?] = [] + let adapter = DashboardPrimaryGatewayAdapter( + state: state, + endpoint: { _ in + GatewayConnection.EndpointSnapshot( + config: (url: url, token: "profile-token", password: nil), + tls: DashboardGatewayTestTLS.route(fingerprint: nil), + routeAuthority: nil) + }, + currentTLSFingerprint: { String(repeating: "b", count: 64) }, + persist: { _, fingerprint in + persistedFingerprints.append(fingerprint) + return true + }) + + try await adapter.apply(profileID: "studio") + + #expect(persistedFingerprints.count == 1) + #expect(persistedFingerprints[0] == nil) + } + + @Test func `password only profile cannot be promoted`() async throws { + let state = AppState(preview: true) + let url = try #require(URL(string: "wss://studio.example:443/")) + let adapter = DashboardPrimaryGatewayAdapter( + state: state, + endpoint: { _ in + GatewayConnection.EndpointSnapshot( + config: (url: url, token: nil, password: "secret"), + routeAuthority: nil) + }) + await #expect(throws: DashboardPrimaryGatewayError.notPromotable) { + try await adapter.apply(profileID: "studio") + } + } + + @Test func `failed promotion restores previous AppState fields`() async throws { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteUrl = "ws://127.0.0.1:18789" + state.remoteToken = "previous-token" + state.connectionMode = .local + let url = try #require(URL(string: "wss://studio.example:443/")) + let previousFingerprint = String(repeating: "b", count: 64) + let profileFingerprint = String(repeating: "a", count: 64) + var persistedFingerprints: [String?] = [] + let adapter = DashboardPrimaryGatewayAdapter( + state: state, + endpoint: { _ in + GatewayConnection.EndpointSnapshot( + config: (url: url, token: "profile-token", password: nil), + tls: DashboardGatewayTestTLS.route(fingerprint: profileFingerprint), + routeAuthority: nil) + }, + currentTLSFingerprint: { previousFingerprint }, + persist: { _, fingerprint in + persistedFingerprints.append(fingerprint) + return persistedFingerprints.count > 1 + }) + + await #expect(throws: DashboardPrimaryGatewayError.notPromotable) { + try await adapter.apply(profileID: "studio") + } + #expect(state.remoteTransport == .ssh) + #expect(state.remoteUrl == "ws://127.0.0.1:18789") + #expect(state.remoteToken == "previous-token") + #expect(state.connectionMode == .local) + #expect(persistedFingerprints == [profileFingerprint, previousFingerprint]) + } +} + +private enum DashboardGatewayTestTLS { + static func route(fingerprint: String?) -> GatewayTLSRoute { + GatewayTLSRoute( + params: GatewayTLSParams( + required: true, + expectedFingerprint: fingerprint, + allowTOFU: fingerprint == nil, + storeKey: nil), + allowsTrustedPinReplacement: true) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index 5f79dc79ecd0..42b154354c77 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -132,6 +132,92 @@ enum GatewayTLSValidationPolicy { } } +public enum GatewayTLSServerTrustDecision: Equatable, Sendable { + case accept + case reject +} + +enum GatewayTLSServerTrustEvaluation { + case accept(fingerprint: String?, enforcePin: Bool) + case reject(failure: GatewayTLSValidationFailure, enforcedFingerprint: String?) +} + +public enum GatewayTLSServerTrust { + public static func evaluate( + trust: SecTrust, + host: String, + port: Int, + params: GatewayTLSParams) -> GatewayTLSServerTrustDecision + { + let expectedFingerprint = params.expectedFingerprint ?? params.storeKey.flatMap { + GatewayTLSStore.loadFingerprint(stableID: $0) + } + return switch self.evaluate( + trust: trust, + host: host, + port: port, + params: params, + expectedFingerprint: expectedFingerprint) + { + case .accept: + .accept + case .reject: + .reject + } + } + + static func evaluate( + trust: SecTrust, + host: String, + port: Int, + params: GatewayTLSParams, + expectedFingerprint: String?) -> GatewayTLSServerTrustEvaluation + { + let systemTrustOk = SecTrustEvaluateWithError(trust, nil) + let fingerprint = certificateFingerprint(trust) + let expected = expectedFingerprint.map(normalizeFingerprint) + let failure: (GatewayTLSValidationFailureKind, String?, String?) -> GatewayTLSServerTrustEvaluation + failure = { kind, expectedFingerprint, enforcedFingerprint in + .reject( + failure: GatewayTLSValidationFailure( + kind: kind, + host: host, + storeKey: params.storeKey, + expectedFingerprint: expectedFingerprint, + observedFingerprint: fingerprint, + systemTrustOk: systemTrustOk, + port: port), + enforcedFingerprint: enforcedFingerprint) + } + switch GatewayTLSValidationPolicy.decide( + expectedFingerprint: expected, + observedFingerprint: fingerprint, + allowTOFU: params.allowTOFU, + required: params.required, + systemTrustOk: systemTrustOk) + { + case let .accept(acceptedFingerprint, enforcePin, saveFirstUse): + guard saveFirstUse else { + return .accept(fingerprint: acceptedFingerprint, enforcePin: enforcePin) + } + guard let acceptedFingerprint, + let storeKey = params.storeKey, + let claimedFingerprint = GatewayTLSStore.claimFirstUseFingerprint( + acceptedFingerprint, + stableID: storeKey) + else { + return failure(.pinStorageUnavailable, nil, nil) + } + guard claimedFingerprint == acceptedFingerprint else { + return failure(.pinMismatch, claimedFingerprint, claimedFingerprint) + } + return .accept(fingerprint: acceptedFingerprint, enforcePin: enforcePin) + case let .reject(kind): + return failure(kind, expected, nil) + } + } +} + final class GatewayTLSFirstUseClaims: @unchecked Sendable { private let lock = NSLock() private var fingerprints: [String: String] = [:] @@ -708,60 +794,21 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS completionHandler(.cancelAuthenticationChallenge, nil) return } - let systemTrustOk = SecTrustEvaluateWithError(trust, nil) - let fingerprint = certificateFingerprint(trust) - let decision = GatewayTLSValidationPolicy.decide( - expectedFingerprint: expected, - observedFingerprint: fingerprint, - allowTOFU: self.params.allowTOFU, - required: self.params.required, - systemTrustOk: systemTrustOk) - - switch decision { - case let .accept(acceptedFingerprint, enforcePin, saveFirstUse): - if saveFirstUse { - guard let acceptedFingerprint, - let storeKey = self.params.storeKey, - let claimedFingerprint = GatewayTLSStore.claimFirstUseFingerprint( - acceptedFingerprint, - stableID: storeKey) - else { - self.recordTLSFailure(GatewayTLSValidationFailure( - kind: .pinStorageUnavailable, - host: host, - storeKey: self.params.storeKey, - expectedFingerprint: nil, - observedFingerprint: acceptedFingerprint, - systemTrustOk: systemTrustOk, - port: challenge.protectionSpace.port)) - completionHandler(.cancelAuthenticationChallenge, nil) - return - } - guard claimedFingerprint == acceptedFingerprint else { - self.recordTLSPinExpectation(claimedFingerprint) - self.recordTLSFailure(GatewayTLSValidationFailure( - kind: .pinMismatch, - host: host, - storeKey: storeKey, - expectedFingerprint: claimedFingerprint, - observedFingerprint: acceptedFingerprint, - systemTrustOk: systemTrustOk, - port: challenge.protectionSpace.port)) - completionHandler(.cancelAuthenticationChallenge, nil) - return - } - } - self.recordTLSAcceptance(acceptedFingerprint, enforcePin: enforcePin) + switch GatewayTLSServerTrust.evaluate( + trust: trust, + host: host, + port: port, + params: self.params, + expectedFingerprint: expected) + { + case let .accept(fingerprint, enforcePin): + self.recordTLSAcceptance(fingerprint, enforcePin: enforcePin) completionHandler(.useCredential, URLCredential(trust: trust)) - case let .reject(kind): - self.recordTLSFailure(GatewayTLSValidationFailure( - kind: kind, - host: host, - storeKey: self.params.storeKey, - expectedFingerprint: expected, - observedFingerprint: fingerprint, - systemTrustOk: systemTrustOk, - port: challenge.protectionSpace.port)) + case let .reject(failure, enforcedFingerprint): + if let enforcedFingerprint { + self.recordTLSPinExpectation(enforcedFingerprint) + } + self.recordTLSFailure(failure) completionHandler(.cancelAuthenticationChallenge, nil) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift index bbc4a9f95883..dc77a7ecdef0 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayTLSPinningTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security import Testing @@ -84,6 +85,25 @@ private final class GatewayTLSFakeKeychain: @unchecked Sendable { } } +private let gatewayTLSTestCertificateDER = + Data( + base64Encoded: "MIIDMTCCAhmgAwIBAgIUY2qs5gTY9AYGcm5Ba8TG3ooCnyowDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPZ2F0ZXdheS5leGFtcGxlMB4XDTI2MDcyNTIxNDkxM1oXDTM2MDcyMjIxNDkxM1owGjEYMBYGA1UEAwwPZ2F0ZXdheS5leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtT4Nw7/K1v8hp5+rrtbfhgB3pnLGnjCi53n95Yisv1WH4osvd5oxjoS3OocLzdX5L8Czz66Caq3zX+Bd6FTtWiaAPek7Gc5hJ6lDf+UR2TBhJGgLcIZbrJz2GQGItqJl0XlkShqnhhAXw/8wScG0QdEeEq3OGm2z2IQYagtbYWB2ugb65GuTxjgIHryDISrY1pKAw3UhwhsftqpUQ5e+gVj1qTMUkj8o6+qEBqzKRWAah1mBbjBuv1/dn6dLXSJDM/XFxqQGOStpywQGHIi0EPZBNiPAE2QL9gRQg4YtgbX2gFcIdrrGUVmbDMEY+FVC4q6zsRyVmnxndDlTx791UwIDAQABo28wbTAdBgNVHQ4EFgQUjd+huKP5/FHbm0h2Tgmnjb8c2dowHwYDVR0jBBgwFoAUjd+huKP5/FHbm0h2Tgmnjb8c2dowDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgg9nYXRld2F5LmV4YW1wbGUwDQYJKoZIhvcNAQELBQADggEBAASZeHqh26eec0U30QJmI2I8+60HAGDd1Cd9XpA/13eFXqCGfev8Rk1gfZ+m0NvBDlBlary4jKGYnVA4QNzP23jL4mBEEAqlmO0QMFg4ucKiKtOLmzdnk2utCY7oMw3/Nt1tD0+qBhayL+d2e5t33fYUwEm5s832xONGJUkpJ1MIldXqMovKomlMUgzSNnkGiTv8yY/J1b2W2/LWjL/ZDLd7E/pyLwvfKY5QXlfEKFp2K+brfkkk1tFLRPir6VNm9wXz3HTZTnj2CAHchitY87MXgDVliYpsQD4AIiycrsHOcRkBF/CBX9XH1LL3iolkk8WaLHeDk2jd6+vd3FRrlsU=")! + +private func gatewayTLSTestTrust(systemTrusted: Bool) throws -> SecTrust { + let certificate = try #require(SecCertificateCreateWithData(nil, gatewayTLSTestCertificateDER as CFData)) + let policy = systemTrusted + ? SecPolicyCreateBasicX509() + : SecPolicyCreateSSL(true, "gateway.example" as CFString) + var trust: SecTrust? + try #require(SecTrustCreateWithCertificates(certificate, policy, &trust) == errSecSuccess) + let trustValue = try #require(trust) + if systemTrusted { + try #require(SecTrustSetAnchorCertificates(trustValue, [certificate] as CFArray) == errSecSuccess) + try #require(SecTrustSetAnchorCertificatesOnly(trustValue, true) == errSecSuccess) + } + return trustValue +} + struct GatewayTLSPinningTests { private func withFakeKeychain(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T { let keychain = GatewayTLSFakeKeychain() @@ -129,6 +149,90 @@ struct GatewayTLSPinningTests { saveFirstUse: false)) } + @Test func `server trust evaluator accepts matching pin and rejects mismatch`() throws { + let trust = try gatewayTLSTestTrust(systemTrusted: false) + let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER) + .map { String(format: "%02x", $0) }.joined() + let matching = GatewayTLSParams( + required: true, + expectedFingerprint: fingerprint, + allowTOFU: false, + storeKey: "profile:matching") + let mismatch = GatewayTLSParams( + required: true, + expectedFingerprint: String(repeating: "0", count: 64), + allowTOFU: false, + storeKey: "profile:mismatch") + + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: matching) == .accept) + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: mismatch) == .reject) + } + + @Test func `server trust evaluator claims trusted first use`() throws { + try self.withFakeKeychain { _ in + let trust = try gatewayTLSTestTrust(systemTrusted: true) + let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER) + .map { String(format: "%02x", $0) }.joined() + let params = GatewayTLSParams( + required: true, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: "profile:first-use") + + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: params) == .accept) + #expect(GatewayTLSStore.loadFingerprint(stableID: "profile:first-use") == fingerprint) + } + } + + @Test func `server trust evaluator reuses persisted first use pin`() throws { + try self.withFakeKeychain { _ in + let trust = try gatewayTLSTestTrust(systemTrusted: false) + let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER) + .map { String(format: "%02x", $0) }.joined() + let storeKey = "profile:reconnect" + let params = GatewayTLSParams( + required: true, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: storeKey) + let claimed = GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: storeKey) + #expect(claimed == fingerprint) + + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: params) == .accept) + } + } + + @Test func `server trust evaluator rejects required untrusted first use`() throws { + let trust = try gatewayTLSTestTrust(systemTrusted: false) + let params = GatewayTLSParams( + required: true, + expectedFingerprint: nil, + allowTOFU: true, + storeKey: "profile:untrusted") + + #expect(GatewayTLSServerTrust.evaluate( + trust: trust, + host: "gateway.example", + port: 443, + params: params) == .reject) + } + @Test func `explicit pin mismatch and unavailable certificate fail closed`() { #expect(GatewayTLSValidationPolicy.decide( expectedFingerprint: "expected", diff --git a/docs/docs_map.md b/docs/docs_map.md index 7e844d33834b..cda5497df5f3 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5527,6 +5527,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /platforms/mac/webchat - Headings: - H2: Multiple Gateway windows + - H3: Gateway picker - H2: Quick Chat bar - H2: Launch and debugging - H2: How it is wired diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 31983d26ecba..207a39b4ad9c 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -23,6 +23,7 @@ profile contains a private-network `ws://` or secure `wss://` endpoint and its optional token or password; credentials are stored in the macOS Keychain. Secure profiles maintain their own system-trust-gated first-use certificate pin and do not inherit `gateway.remote.tlsFingerprint` from the primary Gateway. +Dashboard windows enforce that same saved-profile pinning policy. Removing a profile also closes its open windows and shuts down its secondary connection. @@ -41,6 +42,15 @@ capabilities and Talk Mode. Additional Gateway windows are operator-only, so a second Gateway cannot silently retarget global microphone or device controls. Listen/TTS and normal chat actions use the window's own Gateway connection. +### Gateway picker + +The dashboard header shows a Gateway picker when the Mac app has at least two +configured Gateways. Choose a Gateway to replace the current dashboard in the +same window, or Option-click it to open a separate dashboard window. **Set as +primary…** makes the viewed token-authenticated profile the Mac app's primary +Gateway after confirmation; this resets Talk Mode, canvas, and chat +connections. Password-only profiles can be viewed but cannot be made primary. + ## Quick Chat bar Press Option-Space (⌥Space) or choose **Quick Chat** from the menu bar menu to open a floating composer for the main session. Change the global shortcut with the recorder in **Settings → General → Quick Chat shortcut**. diff --git a/ui/src/app/native-gateways.runtime.ts b/ui/src/app/native-gateways.runtime.ts new file mode 100644 index 000000000000..6484646d3e32 --- /dev/null +++ b/ui/src/app/native-gateways.runtime.ts @@ -0,0 +1,89 @@ +export type NativeGateway = { + id: string; + name: string; + kind: "local" | "remote"; + isPrimary: boolean; + canPromote: boolean; + health: "ok" | "error" | "unknown"; +}; + +export type NativeGatewaysSnapshot = { gateways: NativeGateway[]; currentId: string }; +type NativeGatewaysMessage = + | { type: "select" | "open-window" | "set-primary"; id: string } + | { type: "open-settings" }; +type NativeGatewaysWindow = Window & { + __OPENCLAW_NATIVE_GATEWAYS__?: unknown; + webkit?: { + messageHandlers?: { openclawGateways?: { postMessage(message: NativeGatewaysMessage): void } }; + }; +}; + +const NATIVE_GATEWAYS_CHANGED_EVENT = "openclaw:native-gateways-changed"; + +export type NativeGatewaysCapability = { + readonly snapshot: NativeGatewaysSnapshot | null; + subscribe(listener: (snapshot: NativeGatewaysSnapshot) => void): () => void; + select(id: string): void; + openWindow(id: string): void; + setPrimary(id: string): void; + openSettings(): void; +}; + +function snapshotFrom(value: unknown): NativeGatewaysSnapshot | null { + if (!value || typeof value !== "object") { + return null; + } + const snapshot = value as Partial; + // The embedder owns this payload; deep validation only defends the Mac app from itself. + return Array.isArray(snapshot.gateways) && typeof snapshot.currentId === "string" + ? (snapshot as NativeGatewaysSnapshot) + : null; +} + +function createNativeGatewaysCapability(): NativeGatewaysCapability | null { + if (typeof window === "undefined") { + return null; + } + const nativeWindow = window as NativeGatewaysWindow; + const handler = nativeWindow.webkit?.messageHandlers?.openclawGateways; + if (!handler?.postMessage) { + return null; + } + const post = handler.postMessage.bind(handler); + const postWithId = (type: "select" | "open-window" | "set-primary", id: string) => + post({ type, id }); + let snapshot = snapshotFrom(nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"]); + const listeners = new Set<(snapshot: NativeGatewaysSnapshot) => void>(); + const onChange = (event: Event) => { + const next = snapshotFrom((event as CustomEvent).detail); + if (!next) { + return; + } + snapshot = next; + listeners.forEach((listener) => listener(next)); + }; + window.addEventListener(NATIVE_GATEWAYS_CHANGED_EVENT, onChange); + return { + get snapshot() { + return snapshot; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + select: (id) => postWithId("select", id), + openWindow: (id) => postWithId("open-window", id), + setPrimary: (id) => postWithId("set-primary", id), + openSettings: () => post({ type: "open-settings" }), + }; +} + +let singleton: NativeGatewaysCapability | null | undefined; + +// Chat-chunk-owned so this capability never enters the QA-smoke startup bundle. +export function nativeGatewaysCapability(): NativeGatewaysCapability | null { + if (singleton === undefined) { + singleton = createNativeGatewaysCapability(); + } + return singleton; +} diff --git a/ui/src/app/native-gateways.test.ts b/ui/src/app/native-gateways.test.ts new file mode 100644 index 000000000000..af60c9429d5f --- /dev/null +++ b/ui/src/app/native-gateways.test.ts @@ -0,0 +1,114 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const EVENT = "openclaw:native-gateways-changed"; +const snapshot = { + gateways: [ + { + id: "primary", + name: "Local Gateway", + kind: "local" as const, + isPrimary: true, + canPromote: false, + health: "ok" as const, + }, + { + id: "profile:studio", + name: "Studio", + kind: "remote" as const, + isPrimary: false, + canPromote: true, + health: "unknown" as const, + }, + ], + currentId: "primary", +}; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + Reflect.deleteProperty(window, "__OPENCLAW_NATIVE_GATEWAYS__"); + vi.unstubAllGlobals(); + Reflect.deleteProperty(window, "webkit"); +}); + +function installBridge() { + const postMessage = vi.fn(); + vi.stubGlobal("webkit", { messageHandlers: { openclawGateways: { postMessage } } }); + return postMessage; +} + +describe("native gateways", () => { + it("returns null without the WebKit bridge", async () => { + const { nativeGatewaysCapability } = await import("./native-gateways.runtime.ts"); + + expect(nativeGatewaysCapability()).toBeNull(); + }); + + it("initializes from the native global and posts actions", async () => { + const postMessage = installBridge(); + Object.assign(window, { __OPENCLAW_NATIVE_GATEWAYS__: snapshot }); + const { nativeGatewaysCapability } = await import("./native-gateways.runtime.ts"); + const capability = nativeGatewaysCapability(); + + expect(capability?.snapshot).toEqual(snapshot); + capability?.select("profile:studio"); + capability?.openWindow("profile:studio"); + capability?.setPrimary("profile:studio"); + capability?.openSettings(); + expect(postMessage.mock.calls.map(([message]) => message)).toEqual([ + { type: "select", id: "profile:studio" }, + { type: "open-window", id: "profile:studio" }, + { type: "set-primary", id: "profile:studio" }, + { type: "open-settings" }, + ]); + }); + + it("updates from events and stops notifying after unsubscribe", async () => { + installBridge(); + const { nativeGatewaysCapability } = await import("./native-gateways.runtime.ts"); + const capability = nativeGatewaysCapability(); + const listener = vi.fn(); + const unsubscribe = capability?.subscribe(listener); + window.dispatchEvent(new CustomEvent(EVENT, { detail: snapshot })); + expect(capability?.snapshot).toEqual(snapshot); + expect(listener).toHaveBeenCalledWith(snapshot); + unsubscribe?.(); + listener.mockClear(); + window.dispatchEvent( + new CustomEvent(EVENT, { detail: { ...snapshot, currentId: "profile:studio" } }), + ); + expect(listener).not.toHaveBeenCalled(); + }); + + it("reads the latest global when attached lazily, then publishes native updates", async () => { + installBridge(); + const attachedSnapshot = { ...snapshot, currentId: "profile:studio" }; + Object.assign(window, { __OPENCLAW_NATIVE_GATEWAYS__: attachedSnapshot }); + const { nativeGatewaysCapability } = await import("./native-gateways.runtime.ts"); + const capability = nativeGatewaysCapability(); + + expect(capability?.snapshot).toEqual(attachedSnapshot); + + const listener = vi.fn(); + const unsubscribe = capability?.subscribe(listener); + window.dispatchEvent(new CustomEvent(EVENT, { detail: snapshot })); + expect(listener).toHaveBeenCalledWith(snapshot); + unsubscribe?.(); + }); + + it("creates the app-lifetime singleton only once", async () => { + installBridge(); + Object.assign(window, { __OPENCLAW_NATIVE_GATEWAYS__: snapshot }); + const { nativeGatewaysCapability } = await import("./native-gateways.runtime.ts"); + + const first = nativeGatewaysCapability(); + const second = nativeGatewaysCapability(); + + expect(first).not.toBeNull(); + expect(second).toBe(first); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index e5aceaee6f48..f321e0eff108 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3846,6 +3846,15 @@ export const en: TranslationMap = { oneMessage: "{count} message", messages: "{count} messages", activeBranch: "Active branch", + gatewayPicker: { + menuLabel: "Gateway: {gateway}", + primaryTag: "primary", + setPrimary: "Set as primary…", + openSettings: "Gateway settings…", + connected: "Connected", + unreachable: "Unreachable", + unknown: "Unknown status", + }, }, board: { faceLabel: "Thread face", diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index 47a473400b1a..c23188fd0c75 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -4,10 +4,19 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const nativeGateways = vi.hoisted(() => ({ current: null as NativeGatewaysCapability | null })); + // The dedicated unit-mock-registry project keeps this complete, side-effect-only // module mock from sharing a worker's mock registry with component tests. vi.mock("./chat-pane.ts", () => ({})); +vi.mock("../../app/native-gateways.runtime.ts", () => ({ + nativeGatewaysCapability: () => nativeGateways.current, +})); +import type { + NativeGatewaysCapability, + NativeGatewaysSnapshot, +} from "../../app/native-gateways.runtime.ts"; import { loadSettings } from "../../app/settings.ts"; import { UI_COMMAND_EVENT } from "../../components/panel-toggle-contract.ts"; import { SESSION_DRAG_MIME } from "../../lib/sessions/drag.ts"; @@ -26,6 +35,8 @@ type RenderedPane = HTMLElement & { paneTitle: string; narrow: boolean; mergedChrome: boolean; + nativeGateways: NativeGatewaysCapability | null; + gatewaysSnapshot: NativeGatewaysSnapshot | null; onOpenSplitView?: () => void; onClosePane?: (paneId: string) => void; }; @@ -119,6 +130,7 @@ function stubMatchMedia(matches: boolean) { describe("chat page split layout host", () => { beforeEach(() => { + nativeGateways.current = null; vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("sessionStorage", createStorageMock()); localStorage.clear(); @@ -152,6 +164,33 @@ describe("chat page split layout host", () => { expect(typeof itemAt(panes, 0, "rendered pane").onOpenSplitView).toBe("function"); }); + it("passes the chat-owned gateway capability only to the rightmost pane", async () => { + const gatewaySnapshot: NativeGatewaysSnapshot = { + gateways: [], + currentId: "primary", + }; + nativeGateways.current = { + snapshot: gatewaySnapshot, + subscribe: () => () => undefined, + select: vi.fn(), + openWindow: vi.fn(), + setPrimary: vi.fn(), + openSettings: vi.fn(), + }; + const page = new ChatPage(); + page.data = { sessionKey: "main" }; + document.body.append(page); + setLayout(page, createSplitLayout("main")); + await page.updateComplete; + + const panes = [...page.querySelectorAll("openclaw-chat-pane")]; + expect(panes).toHaveLength(2); + expect(panes[0]?.nativeGateways).toBeNull(); + expect(panes[0]?.gatewaysSnapshot).toBeNull(); + expect(panes[1]?.nativeGateways).toBe(nativeGateways.current); + expect(panes[1]?.gatewaysSnapshot).toBe(gatewaySnapshot); + }); + it("passes merged chrome from the shared mobile-nav query", async () => { stubMatchMedia(true); const page = new ChatPage(); diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index a5165ce0f22a..3ffbf13e2487 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -5,6 +5,7 @@ import { property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { mobileNavLayoutMediaQuery, shouldMergeChatChrome } from "../../app/mobile-nav-layout.ts"; +import { nativeGatewaysCapability } from "../../app/native-gateways.runtime.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; import "../../components/resizable-divider.ts"; import { McpAppUnmountGate } from "../../components/mcp-app-unmount.ts"; @@ -74,10 +75,14 @@ export class ChatPage extends OpenClawLightDomElement { @state() private mergedChrome = false; @state() private dropIndicator: DropIndicator | null = null; - private readonly subscriptions = new SubscriptionsController(this).watch( - () => this.context?.sessions, - (sessions, notify) => sessions.subscribe(notify), - ); + private readonly subscriptions = new SubscriptionsController(this) + .watch( + () => this.context?.sessions, + (sessions, notify) => sessions.subscribe(notify), + ) + .watch(nativeGatewaysCapability, (nativeGateways, notify) => + nativeGateways.subscribe(() => notify()), + ); private mediaQuery: MediaQueryList | null = null; private mobileNavMediaQuery: MediaQueryList | null = null; // Light-DOM enter/leave events bubble from every nested child, so only clear @@ -550,8 +555,10 @@ export class ChatPage extends OpenClawLightDomElement { weight: number, splitMode: boolean, ownerKey: string, + showGatewayPicker: boolean, ) { const sessions = this.context?.sessions?.state.result?.sessions ?? []; + const nativeGateways = nativeGatewaysCapability(); // Route keys can be unresolved aliases ("main"); resolve against the // hello defaults and match rows by equivalence like the pane itself // does, or renamed sessions fall back to the generic key-derived title. @@ -579,6 +586,9 @@ export class ChatPage extends OpenClawLightDomElement { .paneTitle=${title} .narrow=${this.narrow} .mergedChrome=${this.mergedChrome && active} + .nativeGateways=${showGatewayPicker ? nativeGateways : null} + .gatewaysSnapshot=${showGatewayPicker ? (nativeGateways?.snapshot ?? null) : null} + .onboarding=${this.closest(".shell--onboarding") !== null} .onOpenSplitView=${splitMode || this.narrow ? undefined : this.openSplitView} .onSplitDown=${splitMode ? this.handleSplitDown : undefined} .onSplitRight=${splitMode ? this.handleSplitRight : undefined} @@ -619,6 +629,7 @@ export class ChatPage extends OpenClawLightDomElement { ? [] : layout.columns; const renderedColumnWeights = this.narrow ? [1] : layout.columnWeights; + const rightmostPane = renderedColumns.at(-1)?.panes.at(-1); return html`
${repeat( @@ -643,6 +654,7 @@ export class ChatPage extends OpenClawLightDomElement { splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"), splitMode, JSON.stringify([column.id, pane.id, pane.sessionKey]), + pane.id === rightmostPane?.id, )} ${paneIndex < column.panes.length - 1 ? html` diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index e62eccff0573..01e991b14482 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -1,3 +1,7 @@ +import type { + NativeGatewaysCapability, + NativeGatewaysSnapshot, +} from "../../app/native-gateways.runtime.ts"; import { consume, applicationContext, @@ -79,6 +83,9 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @property({ attribute: false }) paneTitle = ""; @property({ attribute: false }) narrow = false; @property({ attribute: false }) mergedChrome = false; + @property({ attribute: false }) nativeGateways?: NativeGatewaysCapability | null; + @property({ attribute: false }) gatewaysSnapshot?: NativeGatewaysSnapshot | null; + @property({ attribute: false }) onboarding = false; @property({ attribute: false }) onOpenSplitView?: () => void; @property({ attribute: false }) onSplitDown?: (paneId: string) => void; @property({ attribute: false }) onSplitRight?: (paneId: string) => void; diff --git a/ui/src/pages/chat/chat-pane-header-render.ts b/ui/src/pages/chat/chat-pane-header-render.ts index 213df625579c..08ecdb49ff22 100644 --- a/ui/src/pages/chat/chat-pane-header-render.ts +++ b/ui/src/pages/chat/chat-pane-header-render.ts @@ -149,6 +149,9 @@ export abstract class ChatPaneHeaderRender extends ChatPaneHeader { board.dock, (dock) => this.handleBoardDockChange(dock), ), + nativeGateways: this.nativeGateways, + gatewaysSnapshot: this.gatewaysSnapshot, + onboarding: this.onboarding, onBeginRename: () => row && this.beginHeaderRename(row), onRenameInput: (value) => { this.headerRenameValue = value; diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 8b6d6c74c9f4..ea92574e5dea 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -3,6 +3,10 @@ import { html, nothing, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewaySessionRow } from "../../../api/types.ts"; +import type { + NativeGatewaysCapability, + NativeGatewaysSnapshot, +} from "../../../app/native-gateways.runtime.ts"; import { COMMAND_PALETTE_OPEN_EVENT, SHELL_NAV_DRAWER_TOGGLE_EVENT, @@ -20,8 +24,42 @@ const containers: HTMLElement[] = []; afterEach(() => { containers.splice(0).forEach((container) => container.remove()); + Reflect.deleteProperty(window, "__OPENCLAW_NATIVE_WEB_CHROME__"); }); +function nativeGateways(snapshot: NativeGatewaysSnapshot): NativeGatewaysCapability { + return { + snapshot, + subscribe: () => () => undefined, + select: vi.fn(), + openWindow: vi.fn(), + setPrimary: vi.fn(), + openSettings: vi.fn(), + }; +} + +const gatewaySnapshot: NativeGatewaysSnapshot = { + gateways: [ + { + id: "primary", + name: "Local Gateway", + kind: "local", + isPrimary: true, + canPromote: false, + health: "ok", + }, + { + id: "profile:studio", + name: "Studio", + kind: "remote", + isPrimary: false, + canPromote: true, + health: "unknown", + }, + ], + currentId: "primary", +}; + function row(patch: Partial = {}): GatewaySessionRow { return { key: "agent:main:test", kind: "direct", updatedAt: 0, ...patch }; } @@ -62,11 +100,111 @@ function mount(patch: Partial = {}) { onBranchSelect: vi.fn(), ...patch, }; + props.gatewaysSnapshot ??= props.nativeGateways?.snapshot; render(html`${renderChatPaneHeader(props)}`, container); return { container, props }; } describe("chat pane header", () => { + it("hides the gateway picker without capability and with one gateway", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + expect(mount().container.querySelector(".chat-pane__gateway-menu")).toBeNull(); + const one = nativeGateways({ gateways: [gatewaySnapshot.gateways[0]!], currentId: "primary" }); + expect( + mount({ nativeGateways: one }).container.querySelector(".chat-pane__gateway-menu"), + ).toBeNull(); + }); + + it("renders gateway rows, primary tag, and current checkmark", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + const { container } = mount({ nativeGateways: nativeGateways(gatewaySnapshot) }); + const rows = container.querySelectorAll(".chat-pane__gateway-item"); + expect(rows).toHaveLength(2); + expect(rows[0]?.textContent).toContain("Local Gateway"); + expect(rows[0]?.textContent).toContain("primary"); + expect(rows[0]?.querySelector(".chat-pane__gateway-check")).not.toBeNull(); + }); + + it("selects normally and opens a new window on alt-click", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + const select = vi.fn(); + const openWindow = vi.fn(); + const capability = { ...nativeGateways(gatewaySnapshot), select, openWindow }; + const first = mount({ nativeGateways: capability }).container.querySelectorAll( + ".chat-pane__gateway-item", + )[1]; + first?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(select).toHaveBeenCalledWith("profile:studio"); + const second = mount({ nativeGateways: capability }).container.querySelectorAll( + ".chat-pane__gateway-item", + )[1]; + second?.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true })); + expect(openWindow).toHaveBeenCalledWith("profile:studio"); + }); + + it("opens a new window when alt-clicking the current gateway", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + const select = vi.fn(); + const openWindow = vi.fn(); + const capability = { ...nativeGateways(gatewaySnapshot), select, openWindow }; + const current = mount({ nativeGateways: capability }).container.querySelector( + ".chat-pane__gateway-item", + ); + current?.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true })); + expect(openWindow).toHaveBeenCalledWith("primary"); + expect(select).not.toHaveBeenCalled(); + }); + + it("re-renders gateway rows from a changed snapshot property", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + let current = gatewaySnapshot; + const capability = { + ...nativeGateways(gatewaySnapshot), + get snapshot() { + return current; + }, + }; + const mounted = mount({ nativeGateways: capability, gatewaysSnapshot: current }); + const next = { + ...gatewaySnapshot, + gateways: [ + ...gatewaySnapshot.gateways, + { + id: "profile:backup", + name: "Backup", + kind: "remote" as const, + isPrimary: false, + canPromote: true, + health: "unknown" as const, + }, + ], + }; + current = next; + window.dispatchEvent(new CustomEvent("openclaw:native-gateways-changed", { detail: next })); + + const props = { ...mounted.props, gatewaysSnapshot: capability.snapshot }; + render(html`${renderChatPaneHeader(props)}`, mounted.container); + + expect(mounted.container.querySelectorAll(".chat-pane__gateway-item")).toHaveLength(3); + expect(mounted.container.textContent).toContain("Backup"); + }); + + it("disables set-primary when the viewed gateway cannot be promoted", () => { + Object.assign(window, { __OPENCLAW_NATIVE_WEB_CHROME__: true }); + const snapshot = { + ...gatewaySnapshot, + gateways: gatewaySnapshot.gateways.map((gateway) => + Object.assign({}, gateway, { canPromote: false }), + ), + currentId: "profile:studio", + }; + const { container } = mount({ nativeGateways: nativeGateways(snapshot) }); + const item = Array.from(container.querySelectorAll("wa-dropdown-item")).find((candidate) => + candidate.textContent?.includes("Set as primary"), + ); + expect(item?.hasAttribute("disabled")).toBe(true); + }); + it("renders and dispatches merged chrome actions for catalog sessions", () => { const drawerEvents: CustomEvent[] = []; const paletteEvents: Event[] = []; diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 8fb7f1f45670..00f8a00cf238 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -1,5 +1,12 @@ import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import type { GatewaySessionRow, SessionBranch } from "../../../api/types.ts"; +import type { + NativeGatewaysCapability, + NativeGatewaysSnapshot, + NativeGateway, +} from "../../../app/native-gateways.runtime.ts"; +import { isNativeWebChromeHost } from "../../../app/native-web-chrome.ts"; import { beginNativeWindowDrag } from "../../../app/native-window-drag.ts"; import { COMMAND_PALETTE_OPEN_EVENT, @@ -9,6 +16,7 @@ import { import { icons } from "../../../components/icons.ts"; import { renderSessionOwnerChip } from "../../../components/session-owner-chip.ts"; import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts"; +import { syncDropdownItemRadio } from "../../../components/web-awesome.ts"; import "../../../components/tooltip.ts"; import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; @@ -44,6 +52,9 @@ type ChatPaneHeaderProps = { faceControl?: TemplateResult | typeof nothing; sharingControl?: TemplateResult | typeof nothing; boardDockAction?: TemplateResult | typeof nothing; + nativeGateways?: NativeGatewaysCapability | null; + gatewaysSnapshot?: NativeGatewaysSnapshot | null; + onboarding?: boolean; onBeginRename: () => void; onRenameInput: (value: string) => void; onCommitRename: () => void; @@ -123,6 +134,94 @@ export function canRevealSessionWorkspace(params: { ); } +function gatewayHealthLabel(gateway: NativeGateway): string { + if (gateway.health === "ok") { + return t("chat.sessionHeader.gatewayPicker.connected"); + } + if (gateway.health === "error") { + return t("chat.sessionHeader.gatewayPicker.unreachable"); + } + return t("chat.sessionHeader.gatewayPicker.unknown"); +} + +function renderGatewayPicker(props: ChatPaneHeaderProps) { + const capability = props.nativeGateways; + const snapshot = props.gatewaysSnapshot; + if ( + !capability || + !snapshot || + snapshot.gateways.length <= 1 || + props.onboarding || + !isNativeWebChromeHost() + ) { + return nothing; + } + const current = snapshot.gateways.find((gateway) => gateway.id === snapshot.currentId); + if (!current) { + return nothing; + } + const setPrimaryDisabled = current.isPrimary || !current.canPromote; + return html` + + + ${snapshot.gateways.map((gateway) => { + const selected = gateway.id === snapshot.currentId; + return html` syncDropdownItemRadio(element, selected))} + @click=${(event: MouseEvent) => { + if (event.altKey) { + capability.openWindow(gateway.id); + } else if (!selected) { + capability.select(gateway.id); + } + }} + > + + ${gateway.name} + + ${gateway.isPrimary + ? html`${t("chat.sessionHeader.gatewayPicker.primaryTag")}` + : nothing} + ${selected + ? html`${icons.check}` + : nothing} + + `; + })} + + !setPrimaryDisabled && capability.setPrimary(current.id)} + >${t("chat.sessionHeader.gatewayPicker.setPrimary")} + capability.openSettings()} + >${t("chat.sessionHeader.gatewayPicker.openSettings")} + + `; +} + export function renderChatPaneHeader(props: ChatPaneHeaderProps) { const placementState = props.session?.placement?.state; const cloud = isCloudWorkerPlacementState(placementState); @@ -315,6 +414,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ` : nothing} + ${renderGatewayPicker(props)}
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction} ${props.catalog diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index ac8a53cbf315..50aa3df64ce7 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -216,6 +216,88 @@ openclaw-chat-pane { min-width: 0; } +.chat-pane__gateway-menu { + flex: 0 1 auto; + min-width: 0; +} + +.chat-pane__gateway-chip { + display: inline-flex; + max-width: 170px; + height: 26px; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--panel); + color: var(--muted); + font-size: 11px; + cursor: pointer; +} + +.chat-pane__gateway-chip:hover, +.chat-pane__gateway-chip:focus-visible { + border-color: var(--muted-strong); + color: var(--text); + outline: none; +} + +.chat-pane__gateway-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-pane__gateway-health { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--muted); +} + +.chat-pane__gateway-health[data-health="ok"] { + background: var(--ok); +} + +.chat-pane__gateway-health[data-health="error"] { + background: var(--danger); +} + +.chat-pane__gateway-chevron, +.chat-pane__gateway-check { + display: inline-flex; +} + +.chat-pane__gateway-chevron svg, +.chat-pane__gateway-check svg { + width: 12px; + height: 12px; +} + +.chat-pane__gateway-chevron svg { + transform: rotate(180deg); +} + +.chat-pane__gateway-details { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.chat-pane__gateway-primary { + color: var(--muted); + font-size: 11px; +} + +.chat-pane__gateway-divider { + height: 1px; + margin: 4px 0; + background: var(--border); +} + .chat-pane__branches-menu { flex: 0 0 auto; }