mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:51:35 +00:00
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:
committed by
GitHub
parent
533807ab61
commit
aae474ab97
@@ -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 you’re 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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user