mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 10:25:20 -06:00
ae55a4090c
* 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
209 lines
7.8 KiB
Swift
209 lines
7.8 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
import WebKit
|
|
|
|
@MainActor
|
|
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
|
let sessionKey: String
|
|
private let root: URL
|
|
private let sessionDir: URL
|
|
private let schemeHandler: CanvasSchemeHandler
|
|
let webView: WKWebView
|
|
private let watcher: CanvasFileWatcher
|
|
private let container: HoverChromeContainerView
|
|
let presentation: CanvasPresentation
|
|
var preferredPlacement: CanvasPlacement?
|
|
private var debugStatusEnabled = false
|
|
private var debugStatusTitle: String?
|
|
private var debugStatusSubtitle: String?
|
|
private var canvasVisible = false
|
|
private var watchesLocalCanvasFiles = false
|
|
|
|
var onVisibilityChanged: ((Bool) -> Void)?
|
|
|
|
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
|
|
self.sessionKey = sessionKey
|
|
self.root = root
|
|
self.presentation = presentation
|
|
|
|
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
|
|
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
|
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
|
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
|
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
|
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
|
|
|
self.schemeHandler = CanvasSchemeHandler(root: root)
|
|
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
|
|
|
|
let config = WKWebViewConfiguration()
|
|
config.userContentController = WKUserContentController()
|
|
config.preferences.isElementFullscreenEnabled = true
|
|
config.preferences.tabFocusesLinks = true
|
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
canvasWindowLogger.debug("CanvasWindowController init config ready")
|
|
for scheme in CanvasScheme.allSchemes {
|
|
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: scheme)
|
|
}
|
|
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
|
|
|
|
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
|
// Presented documents render against an opaque surface.
|
|
self.webView.setValue(true, forKey: "drawsBackground")
|
|
|
|
let sessionDir = self.sessionDir
|
|
let webView = self.webView
|
|
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
|
|
Task { @MainActor in
|
|
guard let webView else { return }
|
|
|
|
// Only auto-reload when we are showing local canvas content.
|
|
guard let scheme = webView.url?.scheme,
|
|
CanvasScheme.allSchemes.contains(scheme) else { return }
|
|
|
|
let path = webView.url?.path ?? ""
|
|
if path == "/" || path.isEmpty {
|
|
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
|
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
|
if !FileManager().fileExists(atPath: indexA.path),
|
|
!FileManager().fileExists(atPath: indexB.path)
|
|
{
|
|
return
|
|
}
|
|
}
|
|
|
|
webView.reload()
|
|
}
|
|
}
|
|
|
|
self.container = HoverChromeContainerView(containing: self.webView)
|
|
let window = Self.makeWindow(for: presentation, contentView: self.container)
|
|
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
|
|
super.init(window: window)
|
|
|
|
self.webView.navigationDelegate = self
|
|
self.webView.uiDelegate = self
|
|
self.window?.delegate = self
|
|
self.container.onClose = { [weak self] in
|
|
self?.hideCanvas()
|
|
}
|
|
|
|
// Keep event delivery active while hidden so file changes are not lost.
|
|
// The recursive polling fallback is enabled only for visible local Canvas content.
|
|
self.watcher.startEventStream()
|
|
canvasWindowLogger.debug("CanvasWindowController init done")
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
@MainActor deinit {
|
|
self.watcher.stop()
|
|
}
|
|
|
|
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
|
|
self.preferredPlacement = placement
|
|
}
|
|
|
|
func showCanvas(path: String? = nil) {
|
|
if case let .panel(anchorProvider) = presentation {
|
|
presentAnchoredPanel(anchorProvider: anchorProvider)
|
|
if let path {
|
|
self.load(target: path)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The window is built in init, so skip showWindow(_:); it would make the
|
|
// window key and steal focus from the user's current window.
|
|
window?.orderFrontRegardless()
|
|
if let path {
|
|
self.load(target: path)
|
|
}
|
|
self.setCanvasVisible(true)
|
|
}
|
|
|
|
func hideCanvas() {
|
|
if case .panel = self.presentation {
|
|
persistFrameIfPanel()
|
|
}
|
|
window?.orderOut(nil)
|
|
self.setCanvasVisible(false)
|
|
}
|
|
|
|
func load(target: String) {
|
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
|
|
if CanvasScheme.allSchemes.contains(scheme) {
|
|
canvasWindowLogger.debug("canvas load app-local URL")
|
|
self.webView.load(URLRequest(url: url))
|
|
return
|
|
}
|
|
if scheme == "https" || scheme == "http" {
|
|
canvasWindowLogger.debug(
|
|
"canvas load web scheme=\(scheme, privacy: .public) host=\(url.host ?? "-", privacy: .public)")
|
|
self.webView.load(URLRequest(url: url))
|
|
return
|
|
}
|
|
}
|
|
|
|
guard let url = CanvasScheme.makeURL(
|
|
session: CanvasWindowController.sanitizeSessionKey(sessionKey),
|
|
path: trimmed)
|
|
else {
|
|
canvasWindowLogger
|
|
.error(
|
|
"invalid canvas url session=\(self.sessionKey, privacy: .public)")
|
|
return
|
|
}
|
|
canvasWindowLogger.debug("canvas load local canvas")
|
|
self.webView.load(URLRequest(url: url))
|
|
}
|
|
|
|
func setCanvasVisible(_ visible: Bool) {
|
|
self.canvasVisible = visible
|
|
self.updateFilePolling()
|
|
self.onVisibilityChanged?(visible)
|
|
}
|
|
|
|
func updateFilePollingForCommittedNavigation(to url: URL) {
|
|
// Requested navigations can fail or redirect, so polling follows the
|
|
// committed main-frame document rather than the requested target.
|
|
self.watchesLocalCanvasFiles = CanvasScheme.allSchemes.contains(url.scheme?.lowercased() ?? "")
|
|
self.updateFilePolling()
|
|
}
|
|
|
|
private func updateFilePolling() {
|
|
self.watcher.setPollingEnabled(self.canvasVisible && self.watchesLocalCanvasFiles)
|
|
}
|
|
|
|
var _testIsFilePollingActive: Bool {
|
|
self.watcher.isPolling
|
|
}
|
|
|
|
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
|
|
self.debugStatusEnabled = enabled
|
|
self.debugStatusTitle = title
|
|
self.debugStatusSubtitle = subtitle
|
|
self.applyDebugStatusIfNeeded()
|
|
}
|
|
|
|
func applyDebugStatusIfNeeded() {
|
|
WebViewJavaScriptSupport.applyDebugStatus(
|
|
webView: self.webView,
|
|
enabled: self.debugStatusEnabled,
|
|
title: self.debugStatusTitle,
|
|
subtitle: self.debugStatusSubtitle)
|
|
}
|
|
|
|
var directoryPath: String {
|
|
self.sessionDir.path
|
|
}
|
|
}
|