Files
openclaw/apps/macos/Sources/OpenClaw/CanvasManager.swift
Peter Steinberger ae55a4090c refactor(canvas): make the panel a widget presenter (#126030)
* refactor(canvas): retire legacy host and commands

* refactor(apple): narrow shared Canvas contracts

* refactor(macos): keep Canvas as widget presenter

* refactor(ios): remove Canvas client

* refactor(android): remove Canvas client

* refactor(linux): remove Canvas client

* fix(ci): isolate native locale artifacts

* fix(linux): regenerate companion lockfile

* fix(canvas): refresh native tool display metadata

* test(canvas): align coverage with presenter surface

* test(canvas): remove obsolete asset root seam

* test(canvas): stabilize retirement CI coverage

* refactor(swift): remove orphaned resource wrapper

* test(ios): remove retired canvas layout assertion

* fix(macos): reserve retired canvas command namespace

* refactor(macos): isolate canvas command policy

* fix(canvas): select only eligible macOS panels

* fix(canvas): keep panel selection plugin-owned
2026-08-19 08:21:07 -07:00

280 lines
10 KiB
Swift

import AppKit
import Foundation
import OpenClawIPC
import OpenClawKit
import OSLog
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private static let logger = Logger(subsystem: "ai.openclaw", category: "CanvasManager")
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
private init() {}
var onPanelVisibilityChanged: ((Bool) -> Void)?
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("OpenClaw/canvas", isDirectory: true)
}()
func show(
sessionKey: String,
path: String? = nil,
placement: CanvasPlacement? = nil) throws -> String
{
try self.showDetailed(
sessionKey: sessionKey,
target: path,
placement: placement).directory
}
func showDetailed(
sessionKey: String,
target: String? = nil,
placement: CanvasPlacement? = nil) throws -> CanvasShowResult
{
Self.logger.debug(
"""
showDetailed start session=\(sessionKey, privacy: .public) \
hasTarget=\(target != nil) \
placement=\(placement != nil)
""")
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let normalizedTarget = target?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let ensured = try ensureController(sessionKey: sessionKey)
let controller = ensured.controller
if !ensured.created {
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
controller.load(target: normalizedTarget)
self.refreshDebugStatus()
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: normalizedTarget)
}
self.refreshDebugStatus()
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: nil,
status: .shown,
url: nil)
}
controller.applyPreferredPlacement(placement)
// New session: default to the local document root.
let effectiveTarget = normalizedTarget ?? "/"
Self.logger.debug("showDetailed showCanvas hasExplicitTarget=\(normalizedTarget != nil)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
self.refreshDebugStatus()
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: effectiveTarget)
}
func hide(sessionKey: String) {
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.panelSessionKey == session else { return }
self.panelController?.hideCanvas()
}
func hideAll() {
self.panelController?.hideCanvas()
}
func refreshDebugStatus() {
guard let controller = panelController else { return }
let enabled = AppStateStore.shared.debugPaneEnabled
let mode = AppStateStore.shared.connectionMode
let title: String?
let subtitle: String?
switch mode {
case .remote:
title = "Remote control"
switch ControlChannel.shared.state {
case .connected:
subtitle = "Connected"
case .connecting:
subtitle = "Connecting…"
case .disconnected:
subtitle = "Disconnected"
case let .degraded(message):
subtitle = message.isEmpty ? "Degraded" : message
}
case .local:
title = GatewayProcessManager.shared.status.label
subtitle = mode.rawValue
case .unconfigured:
title = "Unconfigured"
subtitle = mode.rawValue
}
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
let pt = NSEvent.mouseLocation
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
}
// MARK: - Helpers
/// A session switch keeps the single-panel model by replacing the previous panel.
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 = panelController, 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)
guard !trimmed.isEmpty else { return nil }
guard let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() else { return nil }
return scheme == "https" || scheme == "http" || CanvasScheme.allSchemes.contains(scheme)
? url
: nil
}
private func makeShowResult(
directory: String,
target: String?,
effectiveTarget: String) -> CanvasShowResult
{
if let url = Self.directURL(for: effectiveTarget) {
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: .web,
url: url.absoluteString)
}
let sessionDir = URL(fileURLWithPath: directory)
let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget)
let host = sessionDir.lastPathComponent
let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: status,
url: canvasURL)
}
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager()
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed
var path = withoutQuery
if path.hasPrefix("/") {
path.removeFirst()
}
path = path.removingPercentEncoding ?? path
// Root special-case: resolve an existing index document.
if path.isEmpty {
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) {
return .ok
}
return .notFound
}
// Direct file or directory.
var candidate = sessionDir.appendingPathComponent(path, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
return .ok
}
// Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists.
if !path.isEmpty, !path.hasSuffix("/") {
candidate = sessionDir.appendingPathComponent(path, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
}
return .notFound
}
private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) {
return true
}
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
return fm.fileExists(atPath: b.path)
}
}
#if DEBUG
extension CanvasManager {
var _testPanelWindowIsVisible: Bool? {
self.panelController?.window?.isVisible
}
func _testResetPanel() {
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
}
}
#endif