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
This commit is contained in:
Peter Steinberger
2026-07-17 23:40:35 +01:00
committed by GitHub
parent e805dbb615
commit ed2284c31a
5 changed files with 173 additions and 35 deletions

View File

@@ -27683,7 +27683,7 @@
},
{
"kind": "conditional-branch",
"line": 233,
"line": 231,
"path": "apps/macos/Sources/OpenClaw/CanvasManager.swift",
"source": "Degraded",
"surface": "apple",

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -49,6 +49,11 @@ openclaw nodes canvas eval --node <id> --js "document.title"
openclaw nodes canvas snapshot --node <id>
```
`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`.