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 private let container: HoverChromeContainerView 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() } } self.container = HoverChromeContainerView(containing: self.webView) let window = Self.makeWindow(for: presentation, contentView: self.container) super.init(window: window) self.webView.navigationDelegate = self self.window?.delegate = self self.container.onClose = { [weak self] in self?.hideCanvas() } 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) // Keep Canvas below the Voice Wake overlay panel. panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1) 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 HoverChromeContainerView: NSView { private let content: NSView private let chrome: CanvasChromeOverlayView private var tracking: NSTrackingArea? var onClose: (() -> Void)? init(containing content: NSView) { self.content = content self.chrome = CanvasChromeOverlayView(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.alphaValue = 0 self.chrome.onClose = { [weak self] in self?.onClose?() } 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 } private final class CanvasDragHandleView: NSView { override func mouseDown(with event: NSEvent) { self.window?.performDrag(with: event) } override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } } private final class CanvasChromeOverlayView: NSView { var onClose: (() -> Void)? private let dragHandle = CanvasDragHandleView(frame: .zero) private let closeButton: NSButton = { let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close") ?? NSImage(size: NSSize(width: 18, height: 18)) let btn = NSButton(image: img, target: nil, action: nil) btn.isBordered = false btn.bezelStyle = .regularSquare btn.imageScaling = .scaleProportionallyDown btn.contentTintColor = NSColor.secondaryLabelColor btn.toolTip = "Close" return btn }() override init(frame frameRect: NSRect) { super.init(frame: frameRect) self.wantsLayer = true self.layer?.cornerRadius = 12 self.layer?.masksToBounds = true self.layer?.borderWidth = 1 self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor self.dragHandle.translatesAutoresizingMaskIntoConstraints = false self.dragHandle.wantsLayer = true self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor self.addSubview(self.dragHandle) self.closeButton.translatesAutoresizingMaskIntoConstraints = false self.closeButton.target = self self.closeButton.action = #selector(self.handleClose) self.addSubview(self.closeButton) NSLayoutConstraint.activate([ self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor), self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor), self.dragHandle.heightAnchor.constraint(equalToConstant: 30), self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), self.closeButton.widthAnchor.constraint(equalToConstant: 18), self.closeButton.heightAnchor.constraint(equalToConstant: 18), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func hitTest(_ point: NSPoint) -> NSView? { // When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them). guard self.alphaValue > 0.02 else { return nil } if self.closeButton.frame.contains(point) { return self.closeButton } if self.dragHandle.frame.contains(point) { return self.dragHandle } return nil } @objc private func handleClose() { self.onClose?() } } 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 } } }