fix(macos): stop dashboard windows flashing during reconnects (#117112)

* fix(macos): stop dashboard windows flashing during reconnects

* fix(macos): refresh native localization inventory
This commit is contained in:
Peter Steinberger
2026-07-31 16:56:35 -07:00
committed by GitHub
parent 533807ab61
commit aae474ab97
19 changed files with 906 additions and 220 deletions

View File

@@ -32051,7 +32051,7 @@
},
{
"kind": "ui-named-argument",
"line": 246,
"line": 268,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard reconnecting",
"surface": "apple",
@@ -32059,7 +32059,7 @@
},
{
"kind": "ui-named-argument",
"line": 247,
"line": 269,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "The selected Gateway changed.",
"surface": "apple",
@@ -32067,7 +32067,7 @@
},
{
"kind": "ui-named-argument",
"line": 248,
"line": 270,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Waiting for a fresh authenticated connection.",
"surface": "apple",
@@ -32075,7 +32075,7 @@
},
{
"kind": "ui-named-argument",
"line": 404,
"line": 458,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard unavailable",
"surface": "apple",
@@ -32083,7 +32083,7 @@
},
{
"kind": "ui-named-argument",
"line": 406,
"line": 460,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.",
"surface": "apple",
@@ -32091,7 +32091,7 @@
},
{
"kind": "ui-named-argument",
"line": 612,
"line": 640,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Switch Gateway",
"surface": "apple",
@@ -32099,7 +32099,7 @@
},
{
"kind": "ui-named-argument",
"line": 640,
"line": 668,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Open Gateway Window",
"surface": "apple",
@@ -32107,7 +32107,7 @@
},
{
"kind": "conditional-branch",
"line": 744,
"line": 775,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "\\(base)-\\(UUID().uuidString)",
"surface": "apple",
@@ -32115,7 +32115,7 @@
},
{
"kind": "ui-named-argument",
"line": 984,
"line": 1129,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Set Primary Gateway",
"surface": "apple",
@@ -32123,7 +32123,7 @@
},
{
"kind": "conditional-branch",
"line": 903,
"line": 898,
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
"source": "[\\(host)]",
"surface": "apple",
@@ -34939,7 +34939,7 @@
},
{
"kind": "conditional-branch",
"line": 748,
"line": 749,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -34947,7 +34947,7 @@
},
{
"kind": "conditional-branch",
"line": 748,
"line": 749,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",
@@ -36939,7 +36939,7 @@
},
{
"kind": "ui-localized-call",
"line": 311,
"line": 312,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw updated",
"surface": "apple",
@@ -36947,7 +36947,7 @@
},
{
"kind": "ui-localized-call",
"line": 330,
"line": 331,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Finishing your OpenClaw update",
"surface": "apple",
@@ -36955,7 +36955,7 @@
},
{
"kind": "ui-localized-call",
"line": 331,
"line": 332,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Checking the Mac app and Gateway…",
"surface": "apple",
@@ -36963,7 +36963,7 @@
},
{
"kind": "ui-localized-call",
"line": 439,
"line": 440,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway recovery failed.",
"surface": "apple",
@@ -36971,7 +36971,7 @@
},
{
"kind": "ui-localized-call",
"line": 440,
"line": 441,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The managed OpenClaw runtime could not be reinstalled.",
"surface": "apple",
@@ -36979,7 +36979,7 @@
},
{
"kind": "ui-localized-call",
"line": 447,
"line": 448,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Restarting and verifying the Gateway…",
"surface": "apple",
@@ -36987,7 +36987,7 @@
},
{
"kind": "ui-localized-call",
"line": 448,
"line": 449,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Verifying the Mac node runtime…",
"surface": "apple",
@@ -36995,7 +36995,7 @@
},
{
"kind": "ui-localized-call",
"line": 462,
"line": 463,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Letting your agent know youre back…",
"surface": "apple",
@@ -37003,7 +37003,7 @@
},
{
"kind": "ui-localized-call",
"line": 494,
"line": 495,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Welcome back",
"surface": "apple",
@@ -37011,7 +37011,7 @@
},
{
"kind": "ui-localized-call",
"line": 496,
"line": 497,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw \\(receipt.toVersion) and its Gateway are ready.",
"surface": "apple",
@@ -37019,7 +37019,7 @@
},
{
"kind": "ui-localized-call",
"line": 497,
"line": 498,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw \\(receipt.toVersion) and its Mac node runtime are ready.",
"surface": "apple",
@@ -37027,7 +37027,7 @@
},
{
"kind": "ui-localized-call",
"line": 500,
"line": 501,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Your agent could not be notified yet. OpenClaw will retry after the next app launch.",
"surface": "apple",
@@ -37035,7 +37035,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 504,
"line": 505,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not notify your agent automatically. The app and Gateway update are complete.",
"surface": "apple",
@@ -37043,7 +37043,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 510,
"line": 511,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not notify your agent automatically. The app and Mac node update are complete.",
"surface": "apple",
@@ -37051,7 +37051,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 517,
"line": 518,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not confirm the agent notification. It will not retry, to avoid a duplicate welcome.",
"surface": "apple",
@@ -37059,7 +37059,7 @@
},
{
"kind": "ui-localized-call",
"line": 523,
"line": 524,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The remote Gateway is older than this Mac app, so OpenClaw skipped the agent notification.",
"surface": "apple",
@@ -37067,7 +37067,7 @@
},
{
"kind": "ui-localized-call",
"line": 525,
"line": 526,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway remains paused, so OpenClaw did not wake your agent.",
"surface": "apple",
@@ -37075,7 +37075,7 @@
},
{
"kind": "ui-localized-call",
"line": 553,
"line": 554,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway verification failed.",
"surface": "apple",
@@ -37083,7 +37083,7 @@
},
{
"kind": "ui-localized-call",
"line": 554,
"line": 555,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The managed runtime does not match the updated Mac app.",
"surface": "apple",
@@ -37091,7 +37091,7 @@
},
{
"kind": "ui-localized-call",
"line": 560,
"line": 561,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node did not restart.",
"surface": "apple",
@@ -37099,7 +37099,7 @@
},
{
"kind": "ui-localized-call",
"line": 566,
"line": 567,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node did not become ready.",
"surface": "apple",
@@ -37107,7 +37107,7 @@
},
{
"kind": "ui-localized-call",
"line": 567,
"line": 568,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The node service restarted but did not remain running.",
"surface": "apple",
@@ -37115,7 +37115,7 @@
},
{
"kind": "ui-localized-call",
"line": 578,
"line": 579,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway did not start.",
"surface": "apple",
@@ -37123,7 +37123,7 @@
},
{
"kind": "ui-localized-call",
"line": 579,
"line": 580,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The update is installed, but Gateway health did not become ready.",
"surface": "apple",
@@ -37131,7 +37131,7 @@
},
{
"kind": "ui-localized-call",
"line": 591,
"line": 592,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway could not reconnect.",
"surface": "apple",
@@ -37139,7 +37139,7 @@
},
{
"kind": "ui-localized-call",
"line": 593,
"line": 594,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw installed the update but could not verify the Gateway connection.",
"surface": "apple",
@@ -37147,7 +37147,7 @@
},
{
"kind": "ui-localized-call",
"line": 798,
"line": 799,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway could not be checked.",
"surface": "apple",
@@ -37155,7 +37155,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 800,
"line": 801,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not read the Gateway service ownership record. Retry after checking the Gateway LaunchAgent.",
"surface": "apple",
@@ -37163,7 +37163,7 @@
},
{
"kind": "ui-localized-call",
"line": 806,
"line": 807,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node could not be checked.",
"surface": "apple",
@@ -37171,7 +37171,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 808,
"line": 809,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not read the node service ownership record. Retry after checking the node LaunchAgent.",
"surface": "apple",
@@ -37179,7 +37179,7 @@
},
{
"kind": "ui-localized-call",
"line": 819,
"line": 820,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway update needs help",
"surface": "apple",
@@ -37187,7 +37187,7 @@
},
{
"kind": "ui-call",
"line": 876,
"line": 877,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Update guide",
"surface": "apple",
@@ -37195,7 +37195,7 @@
},
{
"kind": "ui-call",
"line": 877,
"line": 878,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Ask Discord",
"surface": "apple",
@@ -37203,7 +37203,7 @@
},
{
"kind": "ui-call",
"line": 879,
"line": 880,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Retry",
"surface": "apple",
@@ -37211,7 +37211,7 @@
},
{
"kind": "ui-call",
"line": 885,
"line": 886,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Continue",
"surface": "apple",

View File

@@ -3,20 +3,7 @@ import AppKit
@MainActor
enum AppNavigationActions {
static func openDashboard() {
NSApp.activate(ignoringOtherApps: true)
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
DashboardManager.shared.presentDashboard()
}
static func openChat(sessionKey: String? = nil, agentID: String? = nil, draft: String? = nil) {

View File

@@ -14,6 +14,7 @@ extension CanvasWindowController {
defer: false)
window.title = "OpenClaw Canvas"
window.isReleasedWhenClosed = false
window.isRestorable = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)

View File

@@ -24,21 +24,28 @@ final class DashboardManager {
let displayName: String
}
private struct SupersededDashboardPresentation: Error {}
@ObservationIgnored private var controller: DashboardWindowController?
@ObservationIgnored private var mainTarget = DashboardGatewayTarget.primary
@ObservationIgnored private var auxiliaryWindows: [UUID: AuxiliaryWindowInstance] = [:]
@ObservationIgnored private var auxiliaryWindowOrder: [UUID] = []
@ObservationIgnored private var endpointTask: Task<Void, Never>?
@ObservationIgnored private var presentationTask: Task<Void, Error>?
@ObservationIgnored private var pendingOpenCommands: [DashboardNativeCommand] = []
@ObservationIgnored private var openForCommandTask: Task<Void, Never>?
@ObservationIgnored private var navigationGeneration: UInt64 = 0
@ObservationIgnored private var updater: UpdaterProviding?
@ObservationIgnored private var displayedRouteRevision: UInt64?
@ObservationIgnored private var displayedRouteAuthority: UInt64?
@ObservationIgnored private var endpointGeneration: UInt64 = 0
@ObservationIgnored private var presentationGeneration: UInt64 = 0
@ObservationIgnored private var switchGenerations: [ObjectIdentifier: UInt64] = [:]
@ObservationIgnored private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String?
@ObservationIgnored private let routeProbe: @Sendable () async -> Void
@ObservationIgnored private let endpointStateProvider: @Sendable () async -> GatewayEndpointState
@ObservationIgnored private let mainWindowAutosaveName: String
@ObservationIgnored private let observesGatewayChanges: Bool
private(set) var gatewayEntries: [DashboardGatewayEntry] = []
private(set) var frontmostDashboardTarget: DashboardGatewayTarget?
@ObservationIgnored private var gatewayRefreshObservers: [NSObjectProtocol] = []
@@ -72,6 +79,7 @@ final class DashboardManager {
self.routeProbe = routeProbe
self.endpointStateProvider = endpointStateProvider
self.mainWindowAutosaveName = mainWindowAutosaveName
self.observesGatewayChanges = observeGatewayChanges
if observeGatewayChanges {
let names: [Notification.Name] = [
MacGatewayProfileStore.didChangeNotification,
@@ -111,10 +119,10 @@ final class DashboardManager {
private func handleControlChannelStateChange(_ state: ControlChannel.ConnectionState) async {
guard state == .connected else { return }
// Endpoint readiness can precede device authentication. Replay the
// unchanged route once the control socket owns a usable credential.
// Endpoint readiness can precede device authentication. Reconcile the
// existing document after auth arrives without inventing a route change.
let endpointState = await self.endpointStateProvider()
await self.handleEndpointState(endpointState, forceRouteReplacement: true)
await self.handleEndpointState(endpointState)
}
func configure(updater: UpdaterProviding) {
@@ -140,7 +148,7 @@ final class DashboardManager {
/// 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 }
guard self.observesGatewayChanges, self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
@@ -150,28 +158,30 @@ final class DashboardManager {
}
}
func handleEndpointState(
_ state: GatewayEndpointState,
forceRouteReplacement: Bool = false) async
{
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 }
self.endpointGeneration &+= 1
let generation = self.endpointGeneration
guard let controller, controller.isWindowOpen else { return }
guard case let .ready(mode, url, token, password, routeRevision) = state else {
self.replaceWithRouteFailure(controller)
if controller.currentURL != Self.failureURL || controller.auth.hasCredential {
self.replaceWithRouteFailure(controller)
}
self.displayedRouteRevision = nil
self.displayedRouteAuthority = nil
return
}
let config: GatewayConnection.Config = (url, token, password)
let tlsParams = Self.primaryTLSParams(for: config, mode: mode)
let routeChanged = forceRouteReplacement ||
(self.displayedRouteRevision.map { $0 != routeRevision }
?? (routeRevision > 0) || !controller.hasTLSParams(tlsParams))
var authToken = await self.authTokenProvider(config)
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
if authToken == nil, password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty == nil {
await self.routeProbe()
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
authToken = await self.authTokenProvider(config)
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
}
guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(
for: config,
@@ -184,7 +194,13 @@ final class DashboardManager {
gatewayUrl: Self.websocketURLString(for: dashboardURL),
token: authToken,
password: password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
if routeChanged {
let routeChanged = self.displayedRouteRevision.map { $0 != routeRevision }
?? (routeRevision > 0) || !controller.hasTLSParams(tlsParams)
let credentialChanged = controller.auth.token != auth.token || controller.auth.password != auth.password
if routeChanged || credentialChanged {
if routeChanged {
self.displayedRouteAuthority = nil
}
self.displayedRouteRevision = routeRevision
guard auth.hasCredential else {
self.replaceWithRouteFailure(controller)
@@ -215,37 +231,60 @@ final class DashboardManager {
url: URL,
auth: DashboardWindowAuth,
mode: AppState.ConnectionMode,
tlsParams: GatewayTLSParams?)
tlsParams: GatewayTLSParams?,
present: Bool = false)
{
guard self.controller === current else { return }
self.switchGenerations[ObjectIdentifier(current)] = nil
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let window = current.detachWindowForReplacement()
let replacement = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode),
tlsParams: tlsParams,
gatewaySnapshot: self.snapshot(for: .primary))
gatewaySnapshot: self.snapshot(for: .primary),
reusingWindow: window)
self.controller = replacement
replacement.show(url: url, auth: auth)
replacement.loadInBackground(url: url, auth: auth)
if present {
replacement.show()
}
}
private func replaceWithRouteFailure(_ current: DashboardWindowController) {
guard self.controller === current else { return }
self.switchGenerations[ObjectIdentifier(current)] = nil
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let window = current.detachWindowForReplacement()
let replacement = DashboardWindowController(
url: Self.failureURL,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
updater: self.updater,
updateBridgeEnabled: false,
gatewaySnapshot: self.snapshot(for: .primary))
gatewaySnapshot: self.snapshot(for: .primary),
reusingWindow: window)
self.controller = replacement
replacement.showFailure(
title: "Dashboard reconnecting",
message: "The selected Gateway changed.",
detail: "Waiting for a fresh authenticated connection.")
detail: "Waiting for a fresh authenticated connection.",
present: false)
}
func presentDashboard() {
if self.showConfiguredWindowIfPossible() {
return
}
guard self.presentationTask == nil else { return }
let presentation = self.currentPresentationTask()
Task { @MainActor [weak self] in
do {
try await presentation.value
} catch {
guard !Task.isCancelled, !presentation.isCancelled, let self else { return }
self.showFailure(error)
}
}
}
@discardableResult
@@ -268,13 +307,15 @@ final class DashboardManager {
guard auth.hasCredential else {
return false
}
if let controller, !controller.hasTLSParams(endpoint.tls?.params) {
self.endpointGeneration &+= 1
if let controller, self.requiresIsolatedDashboardDocument(controller, auth: auth, endpoint: endpoint) {
self.replaceController(
controller,
url: url,
auth: auth,
mode: mode,
tlsParams: endpoint.tls?.params)
tlsParams: endpoint.tls?.params,
present: true)
} else if let controller {
controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
} else {
@@ -287,6 +328,7 @@ final class DashboardManager {
self.controller = controller
controller.show(url: url, auth: auth)
}
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
Task { await self.refreshGatewaySnapshots() }
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
@@ -313,41 +355,52 @@ final class DashboardManager {
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
}
private func showResolvedPrimaryDashboard() async throws {
let mode = AppStateStore.shared.connectionMode
self.endpointGeneration &+= 1
let generation = self.endpointGeneration
let originalController = self.controller
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
let endpoint = try await self.primaryEndpoint(mode: mode)
let endpoint: GatewayConnection.EndpointSnapshot
do {
endpoint = try await self.primaryEndpoint(mode: mode)
} catch {
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
throw error
}
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
let config = endpoint.config
dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)")
let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
let token = await self.authTokenProvider(config)
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
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, !controller.hasTLSParams(endpoint.tls?.params) {
if let controller, self.requiresIsolatedDashboardDocument(controller, auth: auth, endpoint: endpoint) {
self.replaceController(
controller,
url: url,
auth: auth,
mode: mode,
tlsParams: endpoint.tls?.params)
tlsParams: endpoint.tls?.params,
present: true)
self.rememberPresentedEndpoint(endpoint)
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.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
await self.refreshGatewaySnapshots()
return
@@ -363,6 +416,7 @@ final class DashboardManager {
gatewaySnapshot: self.snapshot(for: .primary))
self.controller = controller
controller.show(url: url, auth: auth)
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
await self.refreshGatewaySnapshots()
@@ -407,6 +461,14 @@ final class DashboardManager {
}
func close() {
self.endpointGeneration &+= 1
self.presentationGeneration &+= 1
self.presentationTask?.cancel()
self.presentationTask = nil
self.navigationGeneration &+= 1
self.openForCommandTask?.cancel()
self.openForCommandTask = nil
self.pendingOpenCommands.removeAll()
self.switchGenerations.removeAll()
self.controller?.closeDashboard()
let controllers = self.auxiliaryWindows.values.map(\.controller)
@@ -418,34 +480,6 @@ final class DashboardManager {
self.frontmostDashboardTarget = nil
}
func handleOnboardingCompletion() {
self.controller?.handleOnboardingCompletion()
}
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()
}
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):
guard self.target(for: source) == target else { return }
self.presentSetPrimaryConfirmation(target, source: source)
case .openSettings:
AppNavigationActions.openSettings(tab: .gateways)
}
}
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
if command.supersedesPendingNavigation {
// This also invalidates a handoff still suspended in show(atPath:).
@@ -467,6 +501,7 @@ final class DashboardManager {
do {
try await self.show()
} catch {
guard !Task.isCancelled else { return }
// Commands are moment-bound; drop them with the failed open.
self.pendingOpenCommands = []
self.showFailure(error)
@@ -562,45 +597,38 @@ final class DashboardManager {
// 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
self.displayedRouteAuthority = nil
}
source.releaseFrameAutosaveForReplacement()
source.closeDashboard()
let windowAutosaveName = self.availableAutosaveName(for: target, replacing: source)
let window = source.detachWindowForReplacement()
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) }
windowAutosaveName: windowAutosaveName,
auxiliary: false,
reusingWindow: window)
self.controller = replacement
if shouldPresent {
replacement.show(url: configuration.url, auth: configuration.auth)
} else {
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
if shouldPresent, present == true || !replacement.isWindowOpen {
replacement.show()
}
} 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 window = source.detachWindowForReplacement()
let replacement = self.makeController(
configuration: configuration,
target: target,
windowAutosaveName: autosaveName,
auxiliary: true)
if let frame { replacement.window?.setFrame(frame, display: false) }
auxiliary: true,
reusingWindow: window)
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)
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
if shouldPresent, present == true || !replacement.isWindowOpen {
replacement.show()
}
}
self.finishSwitch(generation, for: source)
@@ -683,7 +711,8 @@ final class DashboardManager {
configuration: WindowConfiguration,
target: DashboardGatewayTarget,
windowAutosaveName: String,
auxiliary: Bool) -> DashboardWindowController
auxiliary: Bool,
reusingWindow: NSWindow? = nil) -> DashboardWindowController
{
let primaryLocal = !auxiliary && target == .primary && configuration.mode == .local
if primaryLocal {
@@ -695,7 +724,8 @@ final class DashboardManager {
tlsParams: configuration.tlsParams,
gatewaySnapshot: self.snapshot(for: target),
windowTitle: configuration.displayName,
windowAutosaveName: windowAutosaveName)
windowAutosaveName: windowAutosaveName,
reusingWindow: reusingWindow)
}
return DashboardWindowController(
url: configuration.url,
@@ -706,6 +736,7 @@ final class DashboardManager {
gatewaySnapshot: self.snapshot(for: target),
windowTitle: configuration.displayName,
windowAutosaveName: windowAutosaveName,
reusingWindow: reusingWindow,
requestBrowserProfileImportOffer: { _ in false })
}
@@ -841,6 +872,63 @@ final class DashboardManager {
return nil
}
}
extension DashboardManager {
func show() async throws {
try await self.currentPresentationTask().value
}
private func showResolvedDashboard() 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
}
self.observeEndpointChanges()
while true {
do {
try await self.showResolvedPrimaryDashboard()
return
} catch is SupersededDashboardPresentation {
guard !Task.isCancelled, self.mainTarget == .primary else {
throw CancellationError()
}
if let controller, controller.isWindowOpen {
controller.show()
return
}
}
}
}
private func currentPresentationTask() -> Task<Void, Error> {
if let presentationTask {
return presentationTask
}
self.presentationGeneration &+= 1
let generation = self.presentationGeneration
let presentationTask = Task<Void, Error> { @MainActor [weak self] in
guard let self else { throw CancellationError() }
defer {
if self.presentationGeneration == generation {
self.presentationTask = nil
}
}
try await self.showResolvedDashboard()
}
self.presentationTask = presentationTask
return presentationTask
}
private func endpointTransitionIsCurrent(_ generation: UInt64, controller: DashboardWindowController) -> Bool {
self.endpointGeneration == generation && self.controller === controller &&
self.mainTarget == .primary && controller.isWindowOpen
}
private static func primaryTLSParams(
for config: GatewayConnection.Config,
@@ -872,9 +960,66 @@ final class DashboardManager {
password: (config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty))
return auth.hasCredential ? (mode, url, auth, endpoint.tls?.params) : nil
}
}
extension DashboardManager {
private func presentationIsCurrent(
_ generation: UInt64,
controller originalController: DashboardWindowController?) -> Bool
{
guard !Task.isCancelled, self.mainTarget == .primary else {
return false
}
let originalControllerIsCurrent = originalController.map { self.controller === $0 } ?? (self.controller == nil)
return self.endpointGeneration == generation && originalControllerIsCurrent
}
private func requiresIsolatedDashboardDocument(
_ controller: DashboardWindowController,
auth: DashboardWindowAuth,
endpoint: GatewayConnection.EndpointSnapshot) -> Bool
{
!controller.hasTLSParams(endpoint.tls?.params) ||
controller.auth.gatewayUrl != auth.gatewayUrl ||
controller.auth.token != auth.token ||
controller.auth.password != auth.password ||
endpoint.routeAuthority != self.displayedRouteAuthority ||
endpoint.revision.map { $0 != self.displayedRouteRevision } == true
}
private func rememberPresentedEndpoint(_ endpoint: GatewayConnection.EndpointSnapshot) {
if let revision = endpoint.revision {
self.displayedRouteRevision = revision
}
self.displayedRouteAuthority = endpoint.routeAuthority
}
func handleOnboardingCompletion() {
self.controller?.handleOnboardingCompletion()
}
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()
}
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):
guard self.target(for: source) == target else { return }
self.presentSetPrimaryConfirmation(target, source: source)
case .openSettings:
AppNavigationActions.openSettings(tab: .gateways)
}
}
func openOrFocusDashboard(for target: DashboardGatewayTarget) {
Task { await self.performOpenOrFocusDashboard(for: target) }
}
@@ -1062,6 +1207,7 @@ extension DashboardManager {
self.mainTarget = target
if target != .primary {
self.displayedRouteRevision = nil
self.displayedRouteAuthority = nil
}
}

View File

@@ -126,6 +126,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
gatewaySnapshot: DashboardGatewaySnapshot? = nil,
windowTitle: String = "OpenClaw",
windowAutosaveName: String = DashboardWindowLayout.windowFrameAutosaveName,
reusingWindow: NSWindow? = nil,
requestBrowserProfileImportOffer:
@escaping @MainActor (@escaping @MainActor () -> Bool) async -> Bool = { shouldApply in
await BrowserProfileImportModel.shared.requestAutomaticOfferIfEligible(while: shouldApply)
@@ -208,15 +209,21 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.linkBrowserSplitView = linkBrowserSplitView
self.splitViewController = splitViewController
let preservedWindowFrame = reusingWindow?.frame
let restoreKeyboardFocus = reusingWindow?.isKeyWindow == true
let window = Self.makeWindow(
contentView: splitViewController.view,
title: windowTitle,
frameAutosaveName: windowAutosaveName)
frameAutosaveName: windowAutosaveName,
reusing: reusingWindow)
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 = windowAutosaveName
if let preservedWindowFrame {
window.setFrame(preservedWindowFrame, display: false)
}
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
// Width is autosaved, while each new dashboard window starts with the
@@ -238,6 +245,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
self.window?.delegate = self
self.installHistoryStateBridge()
if restoreKeyboardFocus {
window.makeFirstResponder(self.webView)
}
}
func setUpdateBridgeEnabled(_ enabled: Bool) {
@@ -340,26 +350,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
return nil
}
private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
let alert = NSAlert()
alert.messageText = "OpenClaw Dashboard"
if let host, !host.isEmpty {
alert.informativeText = "\(host) is asking:\n\n\(message)"
} else {
alert.informativeText = message
}
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
return alert
}
private static func javaScriptConfirmResult(
for response: NSApplication.ModalResponse)
-> Bool
{
response == .alertFirstButtonReturn
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) is not supported")
@@ -426,14 +416,21 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window?.performClose(nil)
}
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: self.dashboardFrameAutosaveName)
func detachWindowForReplacement() -> NSWindow? {
guard let window else { return nil }
// Route changes replace the privileged document, not its native shell;
// detaching first transfers AppKit ownership without a close/focus cycle.
self.webView.stopLoading()
self.closeLinkBrowser(focusDashboard: false)
self.onClosed = nil
window.delegate = nil
window.saveFrame(usingName: self.dashboardFrameAutosaveName)
self.windowFrameAutosaveName = ""
self.window = nil
return window
}
func showFailure(title: String, message: String, detail: String? = nil) {
func showFailure(title: String, message: String, detail: String? = nil, present: Bool = true) {
self.hasLiveContent = false
self.isShowingFailurePage = true
self.advanceNavigationGeneration()
@@ -449,7 +446,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.webView.loadHTMLString(
DashboardFailurePage.html(title: title, message: message, detail: detail, url: nil),
baseURL: nil)
self.show()
if present {
self.show()
}
}
private func load(_ url: URL) {
@@ -695,12 +694,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
return scheme == "cursor" || scheme == "vscode" || scheme == "windsurf" || scheme == "zed"
}
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() &&
lhs.host?.lowercased() == rhs.host?.lowercased() &&
lhs.port == rhs.port
}
private func refreshNativeAuthScript(url: URL, auth: DashboardWindowAuth) {
let controller = self.webView.configuration.userContentController
controller.removeAllUserScripts()
@@ -739,14 +732,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
""")
}
func navigateBack() {
self.activeNavigationWebView.goBack()
}
func navigateForward() {
self.activeNavigationWebView.goForward()
}
private var activeNavigationWebView: WKWebView {
guard let linkWebView = self.linkBrowser.activeWebView,
let firstResponder = self.window?.firstResponder as? NSView,
@@ -760,13 +745,15 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private static func makeWindow(
contentView: NSView,
title: String,
frameAutosaveName: String) -> NSWindow
frameAutosaveName: String,
reusing existingWindow: NSWindow?) -> NSWindow
{
let window = DashboardWindow(
let window = existingWindow ?? DashboardWindow(
contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
let existingFrame = existingWindow?.frame
let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize))
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(contentView)
@@ -805,18 +792,26 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window.titlebarSeparatorStyle = .none
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
// The singleton manager, not AppKit state restoration, owns this window.
window.isRestorable = false
window.hasShadow = true
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
let viewController = NSViewController()
viewController.view = container
window.contentViewController = viewController
window.center()
if existingWindow == nil {
window.center()
}
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(frameAutosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
if let existingFrame {
window.setFrame(existingFrame, display: false)
} else {
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
}
return window
}
@@ -1033,6 +1028,40 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
extension DashboardWindowController {
func navigateBack() {
self.activeNavigationWebView.goBack()
}
func navigateForward() {
self.activeNavigationWebView.goForward()
}
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() &&
lhs.host?.lowercased() == rhs.host?.lowercased() &&
lhs.port == rhs.port
}
private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
let alert = NSAlert()
alert.messageText = "OpenClaw Dashboard"
if let host, !host.isEmpty {
alert.informativeText = "\(host) is asking:\n\n\(message)"
} else {
alert.informativeText = message
}
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
return alert
}
private static func javaScriptConfirmResult(
for response: NSApplication.ModalResponse)
-> Bool
{
response == .alertFirstButtonReturn
}
/// Commands are deliverable when a document is live or a load is in flight
/// (the queue flushes at `didFinish`). A failure page, or a terminally
/// cancelled load with no successor, needs a reload before dispatch

View File

@@ -15,6 +15,7 @@ enum DebugActions {
defer: false)
window.title = "Agent Events"
window.isReleasedWhenClosed = false
window.isRestorable = false
window.contentView = NSHostingView(rootView: AgentEventsWindow())
window.center()
window.makeKeyAndOrderFront(nil)

View File

@@ -180,11 +180,7 @@ final class DeepLinkHandler {
// MARK: - UI
private func openDashboard() async {
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
AppNavigationActions.openDashboard()
}
private func confirm(title: String, message: String) -> Bool {

View File

@@ -39,11 +39,11 @@ final class DockIconManager: NSObject, @unchecked Sendable {
} ?? []
let hasVisibleWindows = !visibleWindows.isEmpty
if !userWantsDockHidden || hasVisibleWindows {
NSApp?.setActivationPolicy(.regular)
} else {
NSApp?.setActivationPolicy(.accessory)
}
let policy: NSApplication.ActivationPolicy = !userWantsDockHidden || hasVisibleWindows
? .regular
: .accessory
guard NSApp.activationPolicy() != policy else { return }
NSApp.setActivationPolicy(policy)
}
}
@@ -53,6 +53,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
self.logger.warning("NSApp not ready, cannot show Dock icon")
return
}
guard NSApp.activationPolicy() != .regular else { return }
NSApp.setActivationPolicy(.regular)
}
}

View File

@@ -621,16 +621,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
if launchPolicy.shouldAutoOpenDashboard(arguments: CommandLine.arguments) {
self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag")
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
self.openDashboardAction()
}
}

View File

@@ -297,6 +297,10 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
guard fcntl(stdinPipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 else {
self.finishStartLocked(.failure(WorkerError.unavailable("could not protect worker input pipe")))
return
}
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = Array(command.dropFirst())
var environment = ProcessInfo.processInfo.environment
@@ -389,14 +393,16 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
}
private func consumeStdoutLocked(_ data: Data) {
var searchStart = self.stdoutBuffer.count
self.stdoutBuffer.append(data)
guard self.stdoutBuffer.count <= 25 * 1024 * 1024 else {
self.stopLocked(reason: "worker response exceeded limit", notifyUnexpectedExit: true)
return
}
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
while let newline = self.stdoutBuffer[searchStart...].firstIndex(of: 0x0A) {
let line = self.stdoutBuffer.prefix(upTo: newline)
self.stdoutBuffer.removeSubrange(...newline)
searchStart = 0
guard !line.isEmpty,
let message = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any]
else { continue }

View File

@@ -526,6 +526,7 @@ final class OnboardingController: NSObject, NSWindowDelegate {
}
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.isRestorable = false
window.title = UIStrings.welcomeTitle
window.styleMask = Self.windowStyleMask
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))

View File

@@ -308,6 +308,7 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
}
let hosting = NSHostingController(rootView: PostUpdateView(model: model))
let window = NSWindow(contentViewController: hosting)
window.isRestorable = false
window.title = String(localized: "OpenClaw updated")
window.setContentSize(NSSize(width: 560, height: 600))
window.styleMask = OnboardingController.windowStyleMask

View File

@@ -1410,6 +1410,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
(contentViewController as? NSHostingController<MacChatSurface>)?
.sceneBridgingOptions = [.toolbars]
window.isReleasedWhenClosed = false
window.isRestorable = false
// Keep the SwiftUI toolbar controls, but merge their unified row
// with the traffic lights instead of stacking it below a title band.
window.titleVisibility = .hidden

View File

@@ -58,6 +58,7 @@ struct CanvasWindowSmokeTests {
root: root,
presentation: .window)
#expect(controller.window?.isRestorable == false)
controller.showCanvas(path: "/")
controller.windowWillClose(Notification(name: NSWindow.willCloseNotification))
controller.hideCanvas()

View File

@@ -294,6 +294,7 @@ struct DashboardManagerGatewayTargetTests {
token: "current",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
let originalWindow = try #require(controller.window)
let entries = DashboardGatewayTestEntries.withProfiles(["first", "second"])
let manager = DashboardManager._testMake(
profileEndpointProvider: { profileID in
@@ -316,6 +317,7 @@ struct DashboardManagerGatewayTargetTests {
#expect(manager._testMainTarget() == .profile("second"))
#expect(manager._testController()?.currentURL.port == 60003)
#expect(manager._testController()?.window === originalWindow)
}
@Test func `main menu switch replaces the frontmost dashboard in place`() async throws {
@@ -331,7 +333,8 @@ struct DashboardManagerGatewayTargetTests {
controller.window?.setFrame(frame, display: false)
controller.show()
// CI display bounds clamp window frames during show, so compare replacement against the actual source frame.
let sourceFrame = try #require(controller.window).frame
let originalWindow = try #require(controller.window)
let sourceFrame = originalWindow.frame
let entries = DashboardGatewayTestEntries.withProfiles(["studio"])
let manager = DashboardManager._testMake(
profileEndpointProvider: { profileID in
@@ -350,6 +353,7 @@ struct DashboardManagerGatewayTargetTests {
#expect(manager.frontmostDashboardTarget == .profile("studio"))
#expect(manager._testController() !== controller)
#expect(manager._testController()?.currentURL.port == 60002)
#expect(manager._testController()?.window === originalWindow)
#expect(manager._testController()?.window?.frame == sourceFrame)
}

View File

@@ -0,0 +1,493 @@
import AppKit
import Foundation
import Testing
@testable import OpenClaw
private actor DashboardWindowOwnershipAuthGate {
private var value: String?
func authToken() -> String? {
self.value
}
func update(_ value: String) {
self.value = value
}
}
private actor DashboardWindowOwnershipEndpointGate {
private var firstRequested = false
private var firstContinuation: CheckedContinuation<Void, Never>?
func authToken(for config: GatewayConnection.Config) async -> String? {
if config.url.port == 60002 {
self.firstRequested = true
await withCheckedContinuation { continuation in
self.firstContinuation = continuation
}
return "stale"
}
return "current"
}
func waitUntilFirstRequested() async {
while !self.firstRequested {
await Task.yield()
}
}
func releaseFirst() {
self.firstContinuation?.resume()
self.firstContinuation = nil
}
}
private actor DashboardWindowOwnershipPresentationGate {
private var requested = false
private var released = false
private var requestCount = 0
private var continuations: [CheckedContinuation<Void, Never>] = []
func waitForRelease() async {
self.requested = true
self.requestCount += 1
guard !self.released else { return }
await withCheckedContinuation { continuation in
self.continuations.append(continuation)
}
}
func waitUntilRequested() async {
while !self.requested {
await Task.yield()
}
}
func numberOfRequests() -> Int {
self.requestCount
}
func release() {
self.released = true
for continuation in self.continuations {
continuation.resume()
}
self.continuations.removeAll()
}
}
private struct DashboardWindowOwnershipEndpointFailure: Error {}
@MainActor
private final class DashboardWindowOwnershipTrackingWindow: NSWindow {
var simulatesKeyWindow = false
private(set) var foregroundRequestCount = 0
override var isKeyWindow: Bool {
self.simulatesKeyWindow
}
override func makeKeyAndOrderFront(_ sender: Any?) {
self.foregroundRequestCount += 1
super.makeKeyAndOrderFront(sender)
}
}
@Suite(.serialized)
@MainActor
struct DashboardWindowOwnershipTests {
private static let primaryGateway = DashboardGatewayEntry(
id: "primary",
name: "Local Gateway",
kind: "local",
isPrimary: true,
canPromote: false,
health: .ok)
@Test func `disconnect and auth recovery preserve one native window`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipAuthGate()
let readyState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002")),
token: nil,
password: nil,
routeRevision: 2)
let manager = DashboardManager._testMake(
authTokenProvider: { _ in await gate.authToken() },
endpointStateProvider: { readyState })
manager._testSetController(controller)
defer { manager.close() }
await manager.handleEndpointState(readyState)
let failureController = try #require(manager._testController())
#expect(failureController !== controller)
#expect(failureController.window === originalWindow)
#expect(failureController.isWindowOpen)
#expect(failureController.currentURL == URL(string: "about:blank"))
await manager.handleEndpointState(.connecting(mode: .remote, detail: "Connecting"))
await manager.handleEndpointState(.unavailable(mode: .remote, reason: "Unavailable"))
#expect(manager._testController() === failureController)
#expect(failureController.window === originalWindow)
await gate.update("after")
await manager._testHandleControlChannelStateChange(.connected)
let recoveredController = try #require(manager._testController())
#expect(recoveredController !== failureController)
#expect(recoveredController.window === originalWindow)
#expect(recoveredController.currentURL.absoluteString ==
"http://127.0.0.1:60002/#token=after")
let authScripts = recoveredController._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("after"))
#expect(!authScripts[0].source.contains("before"))
await manager._testHandleControlChannelStateChange(.connected)
#expect(manager._testController() === recoveredController)
#expect(recoveredController.window === originalWindow)
}
@Test func `overlapping endpoint updates cannot orphan a dashboard window`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipEndpointGate()
let manager = DashboardManager._testMake(
authTokenProvider: { config in await gate.authToken(for: config) })
manager._testSetController(controller)
defer { manager.close() }
let staleState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002")),
token: nil,
password: nil,
routeRevision: 1)
let currentState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003")),
token: nil,
password: nil,
routeRevision: 2)
let staleUpdate = Task { @MainActor in
await manager.handleEndpointState(staleState)
}
await gate.waitUntilFirstRequested()
await manager.handleEndpointState(currentState)
let currentController = try #require(manager._testController())
await gate.releaseFirst()
await staleUpdate.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
let authScripts = currentController._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("current"))
#expect(!authScripts[0].source.contains("stale"))
}
@Test func `reopening after credential changes isolates the privileged document`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let originalDocument = controller._testDashboardWebViewIdentity
originalWindow.orderOut(nil)
let endpointURL = try #require(URL(string: "ws://127.0.0.1:60001/"))
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
GatewayConnection.EndpointSnapshot(
config: (url: endpointURL, token: "after", password: nil),
routeAuthority: 2,
revision: 2)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
try await manager.show()
let replacement = try #require(manager._testController())
#expect(replacement !== controller)
#expect(replacement.window === originalWindow)
#expect(replacement._testDashboardWebViewIdentity != originalDocument)
let authScripts = replacement._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("after"))
#expect(!authScripts[0].source.contains("before"))
}
@Test func `replacing a key dashboard transfers keyboard ownership`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let originalWindow = DashboardWindowOwnershipTrackingWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)",
reusingWindow: originalWindow)
controller.show()
originalWindow.simulatesKeyWindow = true
let manager = DashboardManager._testMake()
manager._testSetController(controller)
defer { manager.close() }
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002/")),
token: "after",
password: nil,
routeRevision: 2))
let replacement = try #require(manager._testController())
let responder = try #require(originalWindow.firstResponder as? NSView)
#expect(ObjectIdentifier(responder) == replacement._testDashboardWebViewIdentity)
}
@Test func `stale async presentation cannot overwrite a newer endpoint`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let originalWindow = DashboardWindowOwnershipTrackingWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)",
reusingWindow: originalWindow)
controller.show()
let staleEndpointURL = try #require(URL(string: "ws://127.0.0.1:60002/"))
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
return GatewayConnection.EndpointSnapshot(
config: (url: staleEndpointURL, token: "stale", password: nil),
routeAuthority: 1,
revision: 1)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003/")),
token: "current",
password: nil,
routeRevision: 2))
let currentController = try #require(manager._testController())
let backgroundForegroundCount = originalWindow.foregroundRequestCount
await gate.release()
try await presentation.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(originalWindow.foregroundRequestCount > backgroundForegroundCount)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `hidden dashboard invalidates stale reopening authority`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let staleEndpointURL = try #require(URL(string: "ws://127.0.0.1:60002/"))
let currentEndpointURL = try #require(URL(string: "ws://127.0.0.1:60003/"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
originalWindow.orderOut(nil)
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
let request = await gate.numberOfRequests()
let url = request == 1 ? staleEndpointURL : currentEndpointURL
let token = request == 1 ? "stale" : "current"
return GatewayConnection.EndpointSnapshot(
config: (url: url, token: token, password: nil),
routeAuthority: UInt64(request),
revision: UInt64(request))
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
await manager.handleEndpointState(.ready(
mode: .remote,
url: currentEndpointURL,
token: "current",
password: nil,
routeRevision: 2))
await gate.release()
try await presentation.value
let replacement = try #require(manager._testController())
#expect(await gate.numberOfRequests() == 2)
#expect(replacement.window === originalWindow)
#expect(replacement.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `superseded endpoint failure preserves a newer live dashboard`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
throw DashboardWindowOwnershipEndpointFailure()
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003/")),
token: "current",
password: nil,
routeRevision: 2))
let currentController = try #require(manager._testController())
await gate.release()
try await presentation.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `window handoff ignores a conflicting target autosave frame`() throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let originalAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)"
let targetAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)"
defer {
NSWindow.removeFrame(usingName: originalAutosaveName)
NSWindow.removeFrame(usingName: targetAutosaveName)
}
let conflictingWindow = NSWindow(
contentRect: NSRect(x: 30, y: 30, width: 1200, height: 800),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false)
conflictingWindow.isReleasedWhenClosed = false
conflictingWindow.saveFrame(usingName: targetAutosaveName)
conflictingWindow.close()
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: originalAutosaveName)
controller.show()
let originalWindow = try #require(controller.window)
let originalFrame = originalWindow.frame
let transferredWindow = try #require(controller.detachWindowForReplacement())
let replacement = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "after",
password: nil),
windowAutosaveName: targetAutosaveName,
reusingWindow: transferredWindow)
defer { replacement.closeDashboard() }
#expect(replacement.window === originalWindow)
#expect(originalWindow.frame == originalFrame)
}
@Test func `concurrent explicit opens share one presentation owner`() async throws {
let endpointURL = try #require(URL(string: "ws://127.0.0.1:60004/"))
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
return GatewayConnection.EndpointSnapshot(
config: (url: endpointURL, token: "shared", password: nil),
routeAuthority: 1,
revision: 1)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
defer { manager.close() }
let firstPresentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
let secondPresentation = Task { @MainActor in try await manager.show() }
await Task.yield()
#expect(await gate.numberOfRequests() == 1)
await gate.release()
try await firstPresentation.value
try await secondPresentation.value
let controller = try #require(manager._testController())
#expect(controller.isWindowOpen)
#expect(controller.currentURL.absoluteString ==
"http://127.0.0.1:60004/#token=shared")
}
}

View File

@@ -55,6 +55,7 @@ struct DashboardWindowSmokeTests {
controller.show()
#expect(controller.window?.styleMask.contains(.titled) == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
#expect(controller.window?.isRestorable == false)
#expect(controller.window?.contentViewController != nil)
#expect(controller.window?.standardWindowButton(.closeButton) != nil)
// The empty unified toolbar is what grows the titlebar to 52pt so the

View File

@@ -0,0 +1,25 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite(.serialized)
struct MacNodeHostWorkerPipeTests {
@Test func `closed worker input cannot terminate the app with SIGPIPE`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
exec 0<&-
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":[],"commands":[],"pathEnv":"/bin"}}'
sleep 1
"""
_ = try await worker.start(command: ["/bin/sh", "-c", script])
let response = await worker.invoke(BridgeInvokeRequest(
id: "closed",
command: "system.run",
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
#expect(!response.ok)
await worker.stop()
}
}

View File

@@ -155,6 +155,7 @@ struct WebChatSwiftUISmokeTests {
#expect(window.toolbarStyle == .unified)
#expect(window.titlebarSeparatorStyle == .none)
#expect(window.isMovableByWindowBackground)
#expect(window.isRestorable == false)
#expect(window.title == "Studio — OpenClaw")
window.title = "main"
#expect(window.title == "Studio — OpenClaw")