mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 16:01:36 +00:00
* fix(macos): follow gateway endpoint changes in the open Dashboard window In remote SSH mode the Dashboard window baked the forwarded tunnel's local port into the WebView URL and injected auth at open time; when RemoteTunnelManager recreated the tunnel on a new ephemeral port the open window reconnected to the dead port forever. DashboardManager now subscribes to GatewayEndpointStore and, while the window is open, re-injects auth for the new origin and reloads the WebView without stealing focus. Dashboard log lines now strip the #token= fragment so endpoint changes never write credentials to unified logs. Fixes #100476 * test(macos): drop stale hasCrestodianSetupAuth assertions OnboardingView.hasCrestodianSetupAuth was removed with the replaced OnboardingView+CrestodianSetup source in #100288, leaving the macOS test target failing to compile on main. Delete the test for the removed internal helper.
191 lines
7.6 KiB
Swift
191 lines
7.6 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OpenClawKit
|
|
import OSLog
|
|
|
|
private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: "DashboardManager")
|
|
|
|
@MainActor
|
|
final class DashboardManager {
|
|
static let shared = DashboardManager()
|
|
|
|
private var controller: DashboardWindowController?
|
|
private var endpointTask: Task<Void, Never>?
|
|
private static let failureURL = URL(string: "about:blank")!
|
|
|
|
private init() {}
|
|
|
|
/// The remote SSH tunnel can be recreated on a new ephemeral local port while
|
|
/// the dashboard stays open; without following endpoint changes the WebView
|
|
/// keeps reconnecting to the dead old port forever (#100476).
|
|
private func observeEndpointChanges() {
|
|
guard self.endpointTask == nil else { return }
|
|
self.endpointTask = Task { [weak self] in
|
|
let stream = await GatewayEndpointStore.shared.subscribe()
|
|
for await state in stream {
|
|
guard let self else { return }
|
|
await self.handleEndpointState(state)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleEndpointState(_ state: GatewayEndpointState) async {
|
|
guard case let .ready(mode, url, token, password) = state else { return }
|
|
guard let controller, controller.isWindowOpen else { return }
|
|
let config: GatewayConnection.Config = (url, token, password)
|
|
let authToken = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
|
|
guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: authToken),
|
|
dashboardURL != controller.currentURL
|
|
else {
|
|
return
|
|
}
|
|
let auth = DashboardWindowAuth(
|
|
gatewayUrl: Self.websocketURLString(for: dashboardURL),
|
|
token: authToken,
|
|
password: password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
|
guard auth.hasCredential, controller.isWindowOpen else { return }
|
|
dashboardManagerLogger.info(
|
|
"dashboard endpoint changed; reloading url=\(dashboardLogString(for: dashboardURL), privacy: .public)")
|
|
controller.update(url: dashboardURL, auth: auth)
|
|
}
|
|
|
|
@discardableResult
|
|
func showConfiguredWindowIfPossible() -> Bool {
|
|
let mode = AppStateStore.shared.connectionMode
|
|
guard let config = self.immediateDashboardConfig(mode: mode),
|
|
let url = try? GatewayEndpointStore.dashboardURL(
|
|
for: config,
|
|
mode: mode,
|
|
authToken: config.token)
|
|
else {
|
|
return false
|
|
}
|
|
let auth = DashboardWindowAuth(
|
|
gatewayUrl: Self.websocketURLString(for: url),
|
|
token: config.token,
|
|
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
|
guard auth.hasCredential else {
|
|
return false
|
|
}
|
|
if let controller {
|
|
controller.show(url: url, auth: auth)
|
|
} else {
|
|
let controller = DashboardWindowController(url: url, auth: auth)
|
|
self.controller = controller
|
|
controller.show(url: url, auth: auth)
|
|
}
|
|
self.observeEndpointChanges()
|
|
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
|
|
return true
|
|
}
|
|
|
|
func show() async throws {
|
|
let mode = AppStateStore.shared.connectionMode
|
|
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
|
|
let config = try await self.dashboardConfig(mode: mode)
|
|
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)
|
|
let auth = DashboardWindowAuth(
|
|
gatewayUrl: Self.websocketURLString(for: url),
|
|
token: token,
|
|
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
|
|
|
if let controller {
|
|
dashboardManagerLogger.info("dashboard reuse window url=\(dashboardLogString(for: url), privacy: .public)")
|
|
controller.show(url: url, auth: auth)
|
|
self.observeEndpointChanges()
|
|
return
|
|
}
|
|
|
|
dashboardManagerLogger.info("dashboard create window url=\(dashboardLogString(for: url), privacy: .public)")
|
|
let controller = DashboardWindowController(url: url, auth: auth)
|
|
self.controller = controller
|
|
controller.show(url: url, auth: auth)
|
|
self.observeEndpointChanges()
|
|
|
|
// Refresh the cached hello payload without blocking window creation.
|
|
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
|
|
}
|
|
|
|
func showFailure(_ error: Error) {
|
|
let message = (error as NSError).localizedDescription
|
|
dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)")
|
|
let controller = self.controller ?? DashboardWindowController(
|
|
url: Self.failureURL,
|
|
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
|
self.controller = controller
|
|
// Keep observing while the failure page is up so a recovered tunnel
|
|
// swaps the window back to the live dashboard.
|
|
self.observeEndpointChanges()
|
|
controller.showFailure(
|
|
title: "Dashboard unavailable",
|
|
message: message,
|
|
detail: "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.")
|
|
}
|
|
|
|
func close() {
|
|
self.controller?.closeDashboard()
|
|
}
|
|
|
|
private static func websocketURLString(for dashboardURL: URL) -> String {
|
|
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
|
|
return dashboardURL.absoluteString
|
|
}
|
|
switch components.scheme?.lowercased() {
|
|
case "https":
|
|
components.scheme = "wss"
|
|
default:
|
|
components.scheme = "ws"
|
|
}
|
|
components.queryItems = nil
|
|
components.fragment = nil
|
|
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
|
|
}
|
|
|
|
return try await Task.detached(priority: .userInitiated) {
|
|
await GatewayEndpointStore.shared.refresh()
|
|
return try await GatewayEndpointStore.shared.requireConfig()
|
|
}.value
|
|
}
|
|
|
|
private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? {
|
|
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))
|
|
}
|
|
|
|
if mode == .local {
|
|
return GatewayEndpointStore.localConfig()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension DashboardManager {
|
|
/// Test instances skip `observeEndpointChanges()` so the shared endpoint
|
|
/// store cannot race test-driven `handleEndpointState` calls.
|
|
static func _testMake() -> DashboardManager {
|
|
DashboardManager()
|
|
}
|
|
|
|
func _testSetController(_ controller: DashboardWindowController?) {
|
|
self.controller = controller
|
|
}
|
|
}
|
|
#endif
|