368 lines
13 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|