diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 96e839edc979..772bb73f2a85 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/macos/Sources/OpenClaw/AppNavigationActions.swift b/apps/macos/Sources/OpenClaw/AppNavigationActions.swift index eb445a27542c..69feed1e79ce 100644 --- a/apps/macos/Sources/OpenClaw/AppNavigationActions.swift +++ b/apps/macos/Sources/OpenClaw/AppNavigationActions.swift @@ -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) { diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift index 64b9bb3b30a7..8990034a301c 100644 --- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift +++ b/apps/macos/Sources/OpenClaw/CanvasWindowController+Window.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index 9d0e1e503496..d276d701c245 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -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? + @ObservationIgnored private var presentationTask: Task? @ObservationIgnored private var pendingOpenCommands: [DashboardNativeCommand] = [] @ObservationIgnored private var openForCommandTask: Task? @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 { + if let presentationTask { + return presentationTask + } + self.presentationGeneration &+= 1 + let generation = self.presentationGeneration + let presentationTask = Task { @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 } } diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 24e405100f29..e3107105a1b0 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -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 — diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 840b94439ff0..107ea9a28023 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift index 73c1b6a3d494..72436504182e 100644 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/DockIconManager.swift b/apps/macos/Sources/OpenClaw/DockIconManager.swift index 7006464472b6..e656f6e75e62 100644 --- a/apps/macos/Sources/OpenClaw/DockIconManager.swift +++ b/apps/macos/Sources/OpenClaw/DockIconManager.swift @@ -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) } } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index ffb02826cf35..bbd09dc77612 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -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() } } diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift index 2fcb98f4886e..6234cb6310fc 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 6b31f371657c..d7ebe1576f91 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -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)) diff --git a/apps/macos/Sources/OpenClaw/PostUpdate.swift b/apps/macos/Sources/OpenClaw/PostUpdate.swift index a9146d97af96..ed3275b13661 100644 --- a/apps/macos/Sources/OpenClaw/PostUpdate.swift +++ b/apps/macos/Sources/OpenClaw/PostUpdate.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 2e891b4a4d10..75a2c398f33b 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -1410,6 +1410,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { (contentViewController as? NSHostingController)? .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 diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift index 1fb233cd26ae..34dea55c26e2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift @@ -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() diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift index a6b7f9fb54a0..3f3f771404c6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift @@ -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) } diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowOwnershipTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowOwnershipTests.swift new file mode 100644 index 000000000000..18b1e05b4e19 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowOwnershipTests.swift @@ -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? + + 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] = [] + + 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") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index d45e79d9cc81..ed124271033c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -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 diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerPipeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerPipeTests.swift new file mode 100644 index 000000000000..d858d985484d --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerPipeTests.swift @@ -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() + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift index b21d5102dce0..f7540f673e5a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift @@ -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")