mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:31:32 +00:00
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:
committed by
GitHub
parent
48addb3d57
commit
adfb59c19b
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
174
apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift
Normal file
174
apps/macos/Sources/OpenClaw/DashboardGatewayCatalog.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
437
apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift
Normal file
437
apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
89
ui/src/app/native-gateways.runtime.ts
Normal file
89
ui/src/app/native-gateways.runtime.ts
Normal 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;
|
||||
}
|
||||
114
ui/src/app/native-gateways.test.ts
Normal file
114
ui/src/app/native-gateways.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user