feat(mac): add agent-controlled Canvas panel
This commit is contained in:
367
apps/macos/Sources/Clawdis/CanvasWindow.swift
Normal file
367
apps/macos/Sources/Clawdis/CanvasWindow.swift
Normal file
@@ -0,0 +1,367 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
import QuartzCore
|
||||
|
||||
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
||||
|
||||
private enum CanvasLayout {
|
||||
static let panelSize = NSSize(width: 520, height: 680)
|
||||
static let windowSize = NSSize(width: 1120, height: 840)
|
||||
static let anchorPadding: CGFloat = 8
|
||||
}
|
||||
|
||||
final class CanvasPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
}
|
||||
|
||||
enum CanvasPresentation {
|
||||
case window
|
||||
case panel(anchorProvider: () -> NSRect?)
|
||||
|
||||
var isPanel: Bool {
|
||||
if case .panel = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
private let sessionKey: String
|
||||
private let root: URL
|
||||
private let sessionDir: URL
|
||||
private let schemeHandler: CanvasSchemeHandler
|
||||
private let webView: WKWebView
|
||||
private let watcher: CanvasFileWatcher
|
||||
let presentation: CanvasPresentation
|
||||
|
||||
var onVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
|
||||
self.sessionKey = sessionKey
|
||||
self.root = root
|
||||
self.presentation = presentation
|
||||
|
||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
|
||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.userContentController = WKUserContentController()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.setValue(false, forKey: "drawsBackground")
|
||||
|
||||
self.watcher = CanvasFileWatcher(url: self.sessionDir) { [weak webView] in
|
||||
Task { @MainActor in
|
||||
webView?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
let content = HoverChromeContainerView(containing: self.webView)
|
||||
let window = Self.makeWindow(for: presentation, contentView: content)
|
||||
super.init(window: window)
|
||||
|
||||
self.webView.navigationDelegate = self
|
||||
self.window?.delegate = self
|
||||
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
@MainActor deinit {
|
||||
self.watcher.stop()
|
||||
}
|
||||
|
||||
func showCanvas(path: String? = nil) {
|
||||
if case .panel(let anchorProvider) = self.presentation {
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.showWindow(nil)
|
||||
self.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
func hideCanvas() {
|
||||
if case .panel = self.presentation {
|
||||
self.window?.orderOut(nil)
|
||||
} else {
|
||||
self.close()
|
||||
}
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
func goto(path: String) {
|
||||
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: path) else {
|
||||
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(path, privacy: .public)")
|
||||
return
|
||||
}
|
||||
canvasWindowLogger.debug("canvas goto \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async -> String {
|
||||
await withCheckedContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(returning: "error: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(to outPath: String?) async throws -> String {
|
||||
let image: NSImage = try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.takeSnapshot(with: nil) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot returned nil image",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:])
|
||||
else {
|
||||
throw NSError(domain: "Canvas", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "failed to encode png",
|
||||
])
|
||||
}
|
||||
|
||||
let path: String
|
||||
if let outPath, !outPath.isEmpty {
|
||||
path = outPath
|
||||
} else {
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
path = "/tmp/clawdis-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
|
||||
return path
|
||||
}
|
||||
|
||||
var directoryPath: String {
|
||||
self.sessionDir.path
|
||||
}
|
||||
|
||||
// MARK: - Window
|
||||
|
||||
private static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
|
||||
switch presentation {
|
||||
case .window:
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "Clawdis Canvas"
|
||||
window.contentView = contentView
|
||||
window.center()
|
||||
window.minSize = NSSize(width: 880, height: 680)
|
||||
return window
|
||||
|
||||
case .panel:
|
||||
let panel = CanvasPanel(
|
||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.level = .statusBar
|
||||
panel.hasShadow = true
|
||||
panel.isMovable = false
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
panel.backgroundColor = .clear
|
||||
panel.isOpaque = false
|
||||
panel.contentView = contentView
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.hidesOnDeactivate = false
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
self.repositionPanel(using: anchorProvider)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeFirstResponder(self.webView)
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||
guard let panel = self.window else { return }
|
||||
guard let anchor = anchorProvider() else { return }
|
||||
|
||||
var frame = panel.frame
|
||||
let screen = NSScreen.screens.first { screen in
|
||||
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
||||
} ?? NSScreen.main
|
||||
|
||||
if let screen {
|
||||
let minX = screen.frame.minX + CanvasLayout.anchorPadding
|
||||
let maxX = screen.frame.maxX - frame.width - CanvasLayout.anchorPadding
|
||||
frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX)
|
||||
let desiredY = anchor.minY - frame.height - CanvasLayout.anchorPadding
|
||||
frame.origin.y = max(desiredY, screen.frame.minY + CanvasLayout.anchorPadding)
|
||||
} else {
|
||||
frame.origin.x = round(anchor.midX - frame.width / 2)
|
||||
frame.origin.y = anchor.minY - frame.height
|
||||
}
|
||||
panel.setFrame(frame, display: false)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
@MainActor
|
||||
func webView(
|
||||
_: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
|
||||
{
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
if url.scheme == CanvasScheme.scheme {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_: Notification) {
|
||||
self.onVisibilityChanged?(false)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func sanitizeSessionKey(_ key: String) -> String {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "main" }
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
|
||||
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hover chrome container
|
||||
|
||||
private final class PassthroughView: NSView {
|
||||
override func hitTest(_: NSPoint) -> NSView? { nil }
|
||||
}
|
||||
|
||||
private final class HoverChromeContainerView: NSView {
|
||||
private let content: NSView
|
||||
private let chrome: NSView
|
||||
private var tracking: NSTrackingArea?
|
||||
|
||||
init(containing content: NSView) {
|
||||
self.content = content
|
||||
self.chrome = PassthroughView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.wantsLayer = true
|
||||
self.layer?.cornerRadius = 12
|
||||
self.layer?.masksToBounds = true
|
||||
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
|
||||
self.content.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.addSubview(self.content)
|
||||
|
||||
self.chrome.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.chrome.wantsLayer = true
|
||||
self.chrome.layer?.cornerRadius = 12
|
||||
self.chrome.layer?.masksToBounds = true
|
||||
self.chrome.layer?.borderWidth = 1
|
||||
self.chrome.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
||||
self.chrome.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
|
||||
self.chrome.alphaValue = 0
|
||||
self.addSubview(self.chrome)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.content.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
|
||||
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let area = NSTrackingArea(
|
||||
rect: self.bounds,
|
||||
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
|
||||
owner: self,
|
||||
userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.12
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseExited(with _: NSEvent) {
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.16
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
self.chrome.animator().alphaValue = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user