Files
openclaw/apps/macos/Sources/OpenClaw/DashboardManager.swift
Peter Steinberger 96f0983a85 fix(onboarding): skip setup for configured gateways and require inference first (#102883)
* fix(crestodian): keep onboarding RPCs restart-safe

* fix(profiles): isolate approval state migrations

* fix(crestodian): bypass configured gateway setup

* test(crestodian): type onboarding mocks

* fix(onboarding): require inference before Crestodian

* fix(onboarding): enforce verified inference handoff

* fix(macos): reset setup on gateway endpoint edits

* chore(i18n): refresh native source inventory

* fix(gateway): keep socket on request cancellation

* test(packaging): require workspace templates

* fix(onboarding): bind setup to verified inference

* fix(onboarding): align inference gate contracts

* fix(crestodian): classify concurrent policy rejection

* test(crestodian): expect registry restoration

* fix(onboarding): bind setup to configured gateways

* fix(codex): preserve startup phase deadlines

* test(crestodian): match fail-closed policy ordering

* test(onboarding): assert bound gateway handoff

* fix(codex): bind runtime resolution to spawn cwd

* test(crestodian): assert policy rejection order

* fix(cli): preserve gateway routing across restarts

* fix(macos): fail closed during gateway edits

* test(macos): cover gateway route generation races

* chore: keep release notes out of onboarding PR

* fix(ci): refresh onboarding generated checks

* style(swift): align gateway channel formatting

* fix(ci): refresh plugin SDK surface budgets

* fix(ci): resync native string inventory

* refactor(swift): split gateway channel support

* test(doctor): isolate plugin compatibility registry

* test(macos): isolate gateway onboarding fixtures

* test(macos): assert gateway lease health ordering

* fix(codex): reconcile computer-use startup changes
2026-07-11 10:25:14 -07:00

317 lines
13 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 var updater: UpdaterProviding?
private var displayedRouteRevision: UInt64?
private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String?
private let routeProbe: @Sendable () async -> Void
private static let failureURL = URL(string: "about:blank")!
private init(
authTokenProvider: @escaping @Sendable (GatewayConnection.Config) async -> String? = { config in
await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
},
routeProbe: @escaping @Sendable () async -> Void = {
_ = try? await GatewayConnection.shared.request(
method: "health",
params: nil,
timeoutMs: 3000,
retryTransportFailures: false)
})
{
self.authTokenProvider = authTokenProvider
self.routeProbe = routeProbe
}
func configure(updater: UpdaterProviding) {
self.updater = updater
}
/// The card's native update path only makes sense when the app owns the
/// local gateway and the post-relaunch repair is allowed to run; otherwise
/// (external CLI, write-disabled launchd, extended-stable pin) the card
/// must keep the direct gateway `update.run` flow, so no bridge is exposed.
static func updateBridgeEnabled(mode: AppState.ConnectionMode) -> Bool {
guard mode == .local else { return false }
return CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: CLIInstallPrompter.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(),
installPolicy: CLIInstallPolicy.storedPolicy(),
launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled())
}
/// 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 let controller, controller.isWindowOpen else { return }
guard case let .ready(mode, url, token, password, routeRevision) = state else {
self.replaceWithRouteFailure(controller)
self.displayedRouteRevision = nil
return
}
let config: GatewayConnection.Config = (url, token, password)
let routeChanged = self.displayedRouteRevision.map { $0 != routeRevision }
?? (routeRevision > 0)
var authToken = await self.authTokenProvider(config)
if authToken == nil, password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty == nil {
await self.routeProbe()
authToken = await self.authTokenProvider(config)
}
guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(
for: config,
mode: mode,
authToken: authToken)
else {
return
}
let auth = DashboardWindowAuth(
gatewayUrl: Self.websocketURLString(for: dashboardURL),
token: authToken,
password: password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
if routeChanged {
self.displayedRouteRevision = routeRevision
guard auth.hasCredential else {
self.replaceWithRouteFailure(controller)
return
}
self.replaceController(
controller,
url: dashboardURL,
auth: auth,
mode: mode)
return
}
if dashboardURL == controller.currentURL {
self.displayedRouteRevision = routeRevision
controller.setUpdateBridgeEnabled(Self.updateBridgeEnabled(mode: mode))
return
}
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, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
self.displayedRouteRevision = routeRevision
}
private func replaceController(
_ current: DashboardWindowController,
url: URL,
auth: DashboardWindowAuth,
mode: AppState.ConnectionMode)
{
current.closeDashboard()
let replacement = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
self.controller = replacement
replacement.show(url: url, auth: auth)
}
private func replaceWithRouteFailure(_ current: DashboardWindowController) {
current.closeDashboard()
let replacement = DashboardWindowController(
url: Self.failureURL,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
updater: self.updater,
updateBridgeEnabled: false)
self.controller = replacement
replacement.showFailure(
title: "Dashboard reconnecting",
message: "The selected Gateway changed.",
detail: "Waiting for a fresh authenticated connection.")
}
@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, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
} else {
let controller = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
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, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
self.observeEndpointChanges()
return
}
dashboardManagerLogger.info("dashboard create window url=\(dashboardLogString(for: url), privacy: .public)")
let controller = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
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),
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode))
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()
}
func navigateBack() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateBack()
}
func navigateForward() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateForward()
}
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(
authTokenProvider: @escaping @Sendable (GatewayConnection.Config) async -> String? = { $0.token },
routeProbe: @escaping @Sendable () async -> Void = {}) -> DashboardManager
{
DashboardManager(
authTokenProvider: authTokenProvider,
routeProbe: routeProbe)
}
func _testSetController(_ controller: DashboardWindowController?) {
self.controller = controller
}
func _testController() -> DashboardWindowController? {
self.controller
}
}
#endif