Files
clawdbot/apps/macos/Sources/Clawdis/CanvasWindow.swift
2025-12-12 19:54:01 +00:00

368 lines
13 KiB
Swift

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