From ed2284c31a294d7bb705ff4673bb227dc3cef6c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 23:40:35 +0100 Subject: [PATCH] fix(macos): keep Canvas hidden for agent content operations (#110107) * fix(macos): keep Canvas hidden for agent content operations Agent canvas.evalJS, canvas.snapshot, and canvas.a2ui.* commands routed through CanvasManager.show, which re-presented the panel and called NSApp.activate(ignoringOtherApps:) on every content update. After a gateway restart the hosted A2UI page is dead, so the next agent push always re-opened a panel the user had closed and stole focus. Content operations now use a non-presenting ensureController/prepare path; only canvas.present, canvas.navigate, and user actions reveal the panel. Snapshot is read-only: it requires a visible matching panel and returns CANVAS_HIDDEN instead of mutating panel state (WebKit cannot render ordered-out windows). * chore(i18n): refresh native inventory line offsets --- apps/.i18n/native-source.json | 2 +- .../Sources/OpenClaw/CanvasManager.swift | 113 +++++++++++++----- .../OpenClaw/NodeMode/MacNodeRuntime.swift | 8 +- .../CanvasManagerVisibilityTests.swift | 80 +++++++++++++ docs/platforms/mac/canvas.md | 5 + 5 files changed, 173 insertions(+), 35 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/CanvasManagerVisibilityTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9d5dc10474d8..5fb0c73b0f88 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -27683,7 +27683,7 @@ }, { "kind": "conditional-branch", - "line": 233, + "line": 231, "path": "apps/macos/Sources/OpenClaw/CanvasManager.swift", "source": "Degraded", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CanvasManager.swift b/apps/macos/Sources/OpenClaw/CanvasManager.swift index 4b717d66807b..3e35d92ed1a0 100644 --- a/apps/macos/Sources/OpenClaw/CanvasManager.swift +++ b/apps/macos/Sources/OpenClaw/CanvasManager.swift @@ -55,23 +55,20 @@ final class CanvasManager { placement=\(placement != nil) """) let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider - let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedTarget = target? .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty + let ensured = try self.ensureController(sessionKey: sessionKey) + let controller = ensured.controller - if let controller = self.panelController, self.panelSessionKey == session { - Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } + if !ensured.created { controller.presentAnchoredPanel(anchorProvider: anchorProvider) controller.applyPreferredPlacement(placement) - self.refreshDebugStatus() // Existing session: only navigate when an explicit target was provided. if let normalizedTarget { controller.load(target: normalizedTarget, trustedA2UIActions: trustedA2UIActions) + self.refreshDebugStatus() return self.makeShowResult( directory: controller.directoryPath, target: target, @@ -79,6 +76,7 @@ final class CanvasManager { } self.maybeAutoNavigateToA2UIAsync(controller: controller) + self.refreshDebugStatus() return CanvasShowResult( directory: controller.directoryPath, target: target, @@ -87,29 +85,12 @@ final class CanvasManager { url: nil) } - Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)") - self.panelController?.close() - self.panelController = nil - self.panelSessionKey = nil - - Self.logger.debug("showDetailed ensure canvas root dir") - try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) - Self.logger.debug("showDetailed init CanvasWindowController") - let controller = try CanvasWindowController( - sessionKey: session, - root: Self.canvasRoot, - presentation: .panel(anchorProvider: anchorProvider)) - Self.logger.debug("showDetailed CanvasWindowController init done") - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - self.panelController = controller - self.panelSessionKey = session controller.applyPreferredPlacement(placement) // New session: default to "/" so the user sees either the welcome page or `index.html`. let effectiveTarget = normalizedTarget ?? "/" Self.logger.debug("showDetailed showCanvas hasExplicitTarget=\(normalizedTarget != nil)") + // showCanvas presents the panel (presentAnchoredPanel) and loads the target. controller.showCanvas(path: effectiveTarget, trustedA2UIActions: trustedA2UIActions) Self.logger.debug("showDetailed showCanvas done") if normalizedTarget == nil { @@ -134,19 +115,36 @@ final class CanvasManager { } func eval(sessionKey: String, javaScript: String) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { return "" } + let ensured = try self.ensureController(sessionKey: sessionKey) + if ensured.created { + ensured.controller.load(target: "/") + } + let controller = ensured.controller return try await controller.eval(javaScript: javaScript) } func snapshot(sessionKey: String, outPath: String?) async throws -> String { - _ = try self.show(sessionKey: sessionKey, path: nil) - guard let controller = self.panelController else { - throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + // Snapshot is read-only: it must not create, switch, or reveal panel state. + // WebKit suspends rendering for ordered-out windows, so a hidden capture + // has no usable image; refuse with a clear contract instead. + guard let controller = self.panelController, + self.panelSessionKey == session, + controller.window?.isVisible == true + else { + throw NSError(domain: "Canvas", code: 21, userInfo: [ + NSLocalizedDescriptionKey: + "CANVAS_HIDDEN: canvas snapshot needs a visible panel; run canvas.present first", + ]) } return try await controller.snapshot(to: outPath) } + func prepare(sessionKey: String, target: String, trustedA2UIActions: Bool = false) throws { + let controller = try self.ensureController(sessionKey: sessionKey).controller + controller.load(target: target, trustedA2UIActions: trustedA2UIActions) + } + // MARK: - Gateway A2UI auto-nav private func startGatewayObserver() { @@ -253,6 +251,42 @@ final class CanvasManager { // MARK: - Helpers + /// Content operations must not reveal Canvas or activate the app. Only explicit presentation or user intent may + /// re-present it, or reconnect-time agent content would reopen a panel the user closed. + /// A session switch keeps the single-panel model: the previous panel closes and the new one stays hidden. + private func ensureController(sessionKey: String) throws -> (controller: CanvasWindowController, created: Bool) { + let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + + if let controller = self.panelController, self.panelSessionKey == session { + Self.logger.debug("ensureController reuse existing session=\(session, privacy: .public)") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + return (controller, false) + } + + Self.logger.debug("ensureController creating new session=\(session, privacy: .public)") + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + + Self.logger.debug("ensureController ensure canvas root dir") + try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) + Self.logger.debug("ensureController init CanvasWindowController") + let controller = try CanvasWindowController( + sessionKey: session, + root: Self.canvasRoot, + presentation: .panel(anchorProvider: anchorProvider)) + Self.logger.debug("ensureController CanvasWindowController init done") + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.panelController = controller + self.panelSessionKey = session + return (controller, true) + } + private static func directURL(for target: String?) -> URL? { guard let target else { return nil } let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) @@ -348,3 +382,22 @@ final class CanvasManager { // no bundled A2UI shell; scaffold fallback is purely visual } + +#if DEBUG +extension CanvasManager { + var _testPanelWindowIsVisible: Bool? { + self.panelController?.window?.isVisible + } + + var _testHasPanelController: Bool { + self.panelController != nil + } + + func _testResetPanel() { + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + self.lastAutoA2UIUrl = nil + } +} +#endif diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 4e13fe1a65cb..5593f4e36ef7 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -782,9 +782,9 @@ extension MacNodeRuntime { } let sessionKey = self.mainSessionKey _ = try await MainActor.run { - try CanvasManager.shared.show( + try CanvasManager.shared.prepare( sessionKey: sessionKey, - path: a2uiUrl, + target: a2uiUrl, trustedA2UIActions: true) } if await self.isA2UIReady(poll: true) { @@ -792,9 +792,9 @@ extension MacNodeRuntime { } if let refreshedUrl = await self.canvasHostedSurfaceResolver.resolveA2UIURL(forceRefresh: true) { _ = try await MainActor.run { - try CanvasManager.shared.show( + try CanvasManager.shared.prepare( sessionKey: sessionKey, - path: refreshedUrl, + target: refreshedUrl, trustedA2UIActions: true) } if await self.isA2UIReady(poll: true) { diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasManagerVisibilityTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasManagerVisibilityTests.swift new file mode 100644 index 000000000000..f5ddd35fe95c --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/CanvasManagerVisibilityTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct CanvasManagerVisibilityTests { + @Test func `eval on fresh state creates a hidden surface`() async throws { + let manager = CanvasManager.shared + manager._testResetPanel() + defer { manager._testResetPanel() } + + let result = try await manager.eval(sessionKey: "visibility-eval", javaScript: "1 + 1") + + #expect(result == "2") + #expect(manager._testHasPanelController) + #expect(manager._testPanelWindowIsVisible == false) + } + + @Test func `showDetailed presents the panel`() throws { + let manager = CanvasManager.shared + manager._testResetPanel() + defer { manager._testResetPanel() } + + _ = try manager.showDetailed(sessionKey: "visibility-present") + + #expect(manager._testPanelWindowIsVisible == true) + } + + @Test func `content operations respect a user hide`() async throws { + let manager = CanvasManager.shared + manager._testResetPanel() + defer { manager._testResetPanel() } + + _ = try manager.showDetailed(sessionKey: "visibility-hidden") + manager.hideAll() + try manager.prepare(sessionKey: "visibility-hidden", target: "/") + _ = try await manager.eval(sessionKey: "visibility-hidden", javaScript: "1 + 1") + + #expect(manager._testPanelWindowIsVisible == false) + } + + @Test func `snapshot while hidden throws without presenting`() async throws { + let manager = CanvasManager.shared + manager._testResetPanel() + defer { manager._testResetPanel() } + + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-canvas-hidden-\(UUID().uuidString).png") + defer { try? FileManager.default.removeItem(at: output) } + + try manager.prepare(sessionKey: "visibility-snapshot", target: "/") + do { + _ = try await manager.snapshot(sessionKey: "visibility-snapshot", outPath: output.path) + Issue.record("hidden snapshot should throw CANVAS_HIDDEN") + } catch { + #expect(error.localizedDescription.contains("CANVAS_HIDDEN")) + } + + #expect(manager._testHasPanelController) + #expect(manager._testPanelWindowIsVisible == false) + #expect(FileManager.default.fileExists(atPath: output.path) == false) + } + + @Test func `snapshot for another session leaves the visible panel alone`() async throws { + let manager = CanvasManager.shared + manager._testResetPanel() + defer { manager._testResetPanel() } + + _ = try manager.showDetailed(sessionKey: "visibility-live") + #expect(manager._testPanelWindowIsVisible == true) + + await #expect(throws: (any Error).self) { + try await manager.snapshot(sessionKey: "visibility-other", outPath: nil) + } + + // Read-only snapshot must not close or switch the live panel. + #expect(manager._testPanelWindowIsVisible == true) + } +} diff --git a/docs/platforms/mac/canvas.md b/docs/platforms/mac/canvas.md index 933c8878be66..2b6b0025e0ac 100644 --- a/docs/platforms/mac/canvas.md +++ b/docs/platforms/mac/canvas.md @@ -49,6 +49,11 @@ openclaw nodes canvas eval --node --js "document.title" openclaw nodes canvas snapshot --node ``` +`eval` and `a2ui.*` update content without opening or revealing the panel. Only +`present`, `navigate`, or a user action shows it; after a hide, content updates +continue to apply to the hidden panel. `snapshot` needs a visible panel and +returns `CANVAS_HIDDEN` otherwise; run `present` first. + `canvas.navigate` accepts local canvas paths, `http(s)` URLs, and `file://` URLs. Passing `"/"` shows the local scaffold or `index.html`.