mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 01:21:10 +00:00
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:
committed by
GitHub
parent
e805dbb615
commit
ed2284c31a
@@ -27683,7 +27683,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 233,
|
||||
"line": 231,
|
||||
"path": "apps/macos/Sources/OpenClaw/CanvasManager.swift",
|
||||
"source": "Degraded",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user