feat(mac): dashboard gateway picker with in-place switching (#113965)

* feat(mac): add dashboard gateway switching

* feat(ui): add dashboard gateway picker

* docs(mac): document dashboard gateway picker

* fix(mac): harden gateway switching after review

* fix(ui): refresh gateway picker snapshots

* fix(mac): carry TLS pins through promotion and serialize gateway switches

* style(ui): satisfy lint rules in gateway picker files

* fix(ui): tolerate absent context in gateway picker pane props

* chore(ci): refresh generated inventories

* style(mac): satisfy Swift CI checks

* refactor(mac): drop dead GatewayEndpointStore.requireConfig

* perf(ui): own gateway capability in the chat chunk

* chore(ui): keep gateway capability factory module-private
This commit is contained in:
Peter Steinberger
2026-07-25 20:57:28 -07:00
committed by GitHub
parent 48addb3d57
commit adfb59c19b
24 changed files with 2316 additions and 172 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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
}
}
}

View File

@@ -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<Void, Never>?
private var pendingOpenCommands: [DashboardNativeCommand] = []
private var openForCommandTask: Task<Void, Never>?
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

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<Void, Never>?
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)
}
}

View File

@@ -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)
}
}

View File

@@ -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<T>(_ 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",

View File

@@ -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

View File

@@ -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**.

View File

@@ -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<NativeGatewaysSnapshot>;
// 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<unknown>).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;
}

View File

@@ -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);
});
});

View File

@@ -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",

View File

@@ -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<RenderedPane>("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();

View File

@@ -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`
<div class="chat-split-view ${this.narrow ? "chat-split-view--narrow" : ""}">
${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`

View File

@@ -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;

View File

@@ -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;

View File

@@ -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> = {}): GatewaySessionRow {
return { key: "agent:main:test", kind: "direct", updatedAt: 0, ...patch };
}
@@ -62,11 +100,111 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
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<ShellNavDrawerToggleDetail>[] = [];
const paletteEvents: Event[] = [];

View File

@@ -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`
<wa-dropdown class="chat-pane__gateway-menu" placement="bottom-start">
<button
slot="trigger"
class="chat-pane__gateway-chip"
type="button"
aria-label=${t("chat.sessionHeader.gatewayPicker.menuLabel", { gateway: current.name })}
>
<span class="chat-pane__gateway-health" data-health=${current.health}></span>
<span class="chat-pane__gateway-name">${current.name}</span>
<span class="chat-pane__gateway-chevron">${icons.chevronUp}</span>
</button>
${snapshot.gateways.map((gateway) => {
const selected = gateway.id === snapshot.currentId;
return html`<wa-dropdown-item
class="chat-pane__gateway-item"
type="checkbox"
role="menuitemradio"
aria-checked=${String(selected)}
${ref((element) => syncDropdownItemRadio(element, selected))}
@click=${(event: MouseEvent) => {
if (event.altKey) {
capability.openWindow(gateway.id);
} else if (!selected) {
capability.select(gateway.id);
}
}}
>
<span
slot="icon"
class="chat-pane__gateway-health"
data-health=${gateway.health}
role="img"
aria-label=${gatewayHealthLabel(gateway)}
></span>
<span>${gateway.name}</span>
<span slot="details" class="chat-pane__gateway-details">
${gateway.isPrimary
? html`<span class="chat-pane__gateway-primary"
>${t("chat.sessionHeader.gatewayPicker.primaryTag")}</span
>`
: nothing}
${selected
? html`<span class="chat-pane__gateway-check">${icons.check}</span>`
: nothing}
</span>
</wa-dropdown-item>`;
})}
<div class="chat-pane__gateway-divider" role="separator"></div>
<wa-dropdown-item
?disabled=${setPrimaryDisabled}
@click=${() => !setPrimaryDisabled && capability.setPrimary(current.id)}
>${t("chat.sessionHeader.gatewayPicker.setPrimary")}</wa-dropdown-item
>
<wa-dropdown-item @click=${() => capability.openSettings()}
>${t("chat.sessionHeader.gatewayPicker.openSettings")}</wa-dropdown-item
>
</wa-dropdown>
`;
}
export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
const placementState = props.session?.placement?.state;
const cloud = isCloudWorkerPlacementState(placementState);
@@ -315,6 +414,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
</wa-dropdown>
`
: nothing}
${renderGatewayPicker(props)}
<div class="chat-pane__actions">
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction}
${props.catalog

View File

@@ -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;
}