diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index d363b07fe..2fd2bcbe9 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -149,6 +149,10 @@ final class AppState: ObservableObject { didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } } } + @Published var canvasEnabled: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } + } + @Published var attachExistingGatewayOnly: Bool { didSet { self.ifNotPreview { @@ -224,6 +228,7 @@ final class AppState: ObservableObject { self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) self.webChatPort = storedPort > 0 ? storedPort : 18788 + self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) if !self.isPreview { @@ -335,6 +340,7 @@ extension AppState { state.webChatEnabled = true state.webChatSwiftUIEnabled = false state.webChatPort = 18788 + state.canvasEnabled = true state.remoteTarget = "user@example.com" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/clawdis" @@ -367,6 +373,10 @@ enum AppStateStore { return stored > 0 ? stored : 18788 } + static var canvasEnabled: Bool { + UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true + } + static var attachExistingGatewayOnly: Bool { UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) } diff --git a/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift b/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift new file mode 100644 index 000000000..1b4bc82d9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasFileWatcher.swift @@ -0,0 +1,61 @@ +import Foundation +import Darwin + +final class CanvasFileWatcher: @unchecked Sendable { + private let url: URL + private let queue: DispatchQueue + private var source: DispatchSourceFileSystemObject? + private var fd: Int32 = -1 + private var pending = false + private let onChange: () -> Void + + init(url: URL, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: "com.steipete.clawdis.canvaswatcher") + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + guard self.source == nil else { return } + let path = (self.url as NSURL).fileSystemRepresentation + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return } + self.fd = fd + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .attrib, .extend, .link, .revoke], + queue: self.queue) + + source.setEventHandler { [weak self] in + guard let self else { return } + if self.pending { return } + self.pending = true + self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + self.pending = false + self.onChange() + } + } + + source.setCancelHandler { [weak self] in + guard let self else { return } + if self.fd >= 0 { + close(self.fd) + self.fd = -1 + } + } + + self.source = source + source.resume() + } + + func stop() { + self.source?.cancel() + self.source = nil + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift new file mode 100644 index 000000000..51d452486 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasManager.swift @@ -0,0 +1,77 @@ +import AppKit +import Foundation + +@MainActor +final class CanvasManager { + static let shared = CanvasManager() + + private var panelController: CanvasWindowController? + private var panelSessionKey: String? + + /// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor. + var defaultAnchorProvider: (() -> NSRect?)? + + private nonisolated static let canvasRoot: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return base.appendingPathComponent("Clawdis/canvas", isDirectory: true) + }() + + func show(sessionKey: String, path: String? = nil) throws -> String { + let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + if let controller = self.panelController, self.panelSessionKey == session { + controller.presentAnchoredPanel(anchorProvider: anchorProvider) + controller.goto(path: path ?? "/") + return controller.directoryPath + } + + self.panelController?.close() + self.panelController = nil + self.panelSessionKey = nil + + try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) + let controller = try CanvasWindowController( + sessionKey: session, + root: Self.canvasRoot, + presentation: .panel(anchorProvider: anchorProvider)) + self.panelController = controller + self.panelSessionKey = session + controller.showCanvas(path: path ?? "/") + return controller.directoryPath + } + + func hide(sessionKey: String) { + let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.panelSessionKey == session else { return } + self.panelController?.hideCanvas() + } + + func hideAll() { + self.panelController?.hideCanvas() + } + + func goto(sessionKey: String, path: String) throws { + _ = try self.show(sessionKey: sessionKey, path: path) + } + + func eval(sessionKey: String, javaScript: String) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { return "" } + return await controller.eval(javaScript: javaScript) + } + + func snapshot(sessionKey: String, outPath: String?) async throws -> String { + _ = try self.show(sessionKey: sessionKey, path: nil) + guard let controller = self.panelController else { + throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"]) + } + return try await controller.snapshot(to: outPath) + } + + // MARK: - Anchoring + + private static func mouseAnchorProvider() -> NSRect? { + let pt = NSEvent.mouseLocation + return NSRect(x: pt.x, y: pt.y, width: 1, height: 1) + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasScheme.swift b/apps/macos/Sources/Clawdis/CanvasScheme.swift new file mode 100644 index 000000000..0fca58848 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasScheme.swift @@ -0,0 +1,40 @@ +import Foundation + +enum CanvasScheme { + static let scheme = "clawdis-canvas" + + static func makeURL(session: String, path: String? = nil) -> URL? { + var comps = URLComponents() + comps.scheme = Self.scheme + comps.host = session + let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines) + if p.isEmpty || p == "/" { + comps.path = "/" + } else if p.hasPrefix("/") { + comps.path = p + } else { + comps.path = "/" + p + } + return comps.url + } + + static func mimeType(forExtension ext: String) -> String { + switch ext.lowercased() { + case "html", "htm": "text/html; charset=utf-8" + case "js", "mjs": "application/javascript; charset=utf-8" + case "css": "text/css; charset=utf-8" + case "json", "map": "application/json; charset=utf-8" + case "svg": "image/svg+xml" + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "ico": "image/x-icon" + case "woff2": "font/woff2" + case "woff": "font/woff" + case "ttf": "font/ttf" + case "wasm": "application/wasm" + default: "application/octet-stream" + } + } +} + diff --git a/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift new file mode 100644 index 000000000..fce3327f8 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift @@ -0,0 +1,194 @@ +import Foundation +import OSLog +import WebKit + +private let canvasLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") + +final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { + private let root: URL + + init(root: URL) { + self.root = root + } + + func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "missing url", + ])) + return + } + + let response = self.response(for: url) + let mime = response.mime + let data = response.data + + let urlResponse = URLResponse( + url: url, + mimeType: mime, + expectedContentLength: data.count, + textEncodingName: "utf-8") + urlSchemeTask.didReceive(urlResponse) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_: WKWebView, stop _: WKURLSchemeTask) { + // no-op + } + + private struct CanvasResponse { + let mime: String + let data: Data + } + + private func response(for url: URL) -> CanvasResponse { + guard url.scheme == CanvasScheme.scheme else { + return self.html("Invalid scheme.") + } + guard let session = url.host, !session.isEmpty else { + return self.html("Missing session.") + } + + // Keep session component safe; don't allow slashes or traversal. + if session.contains("/") || session.contains("..") { + return self.html("Invalid session.") + } + + let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true) + + // Path mapping: request path maps directly into the session dir. + var path = url.path + if let qIdx = path.firstIndex(of: "?") { path = String(path[.. \(standardizedFile.path, privacy: .public)") + return CanvasResponse(mime: mime, data: data) + } catch { + canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + return self.html("Failed to read file.", title: "Canvas error") + } + } + + private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { + let fm = FileManager.default + var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) + + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) { + if isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + return nil + } + return candidate + } + + // Directory index behavior: + // - "/yolo" serves "/index.html" if that directory exists. + if !requestPath.isEmpty, !requestPath.hasSuffix("/") { + candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true) + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + if let idx = self.resolveIndex(in: candidate) { return idx } + } + } + + // Root fallback: + // - "/" serves "/index.html" if present. + if requestPath.isEmpty { + return self.resolveIndex(in: sessionRoot) + } + + return nil + } + + private func resolveIndex(in dir: URL) -> URL? { + let fm = FileManager.default + let a = dir.appendingPathComponent("index.html", isDirectory: false) + if fm.fileExists(atPath: a.path) { return a } + let b = dir.appendingPathComponent("index.htm", isDirectory: false) + if fm.fileExists(atPath: b.path) { return b } + return nil + } + + private func html(_ body: String, title: String = "Canvas") -> CanvasResponse { + let html = """ + + + + + + \(title) + + + +
+
\(body)
+
+ + + """ + return CanvasResponse(mime: "text/html; charset=utf-8", data: Data(html.utf8)) + } + + private func welcomePage(sessionRoot: URL) -> CanvasResponse { + let escaped = sessionRoot.path + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + let body = """ +
Canvas is ready.
+
Create index.html in:
+
\(escaped)
+ """ + return self.html(body, title: "Canvas") + } +} + diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift new file mode 100644 index 000000000..08ba89ff1 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -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 + } + } +} diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index ccb270189..dece47f1f 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -23,6 +23,7 @@ let remoteProjectRootKey = "clawdis.remoteProjectRoot" let webChatEnabledKey = "clawdis.webChatEnabled" let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled" let webChatPortKey = "clawdis.webChatPort" +let canvasEnabledKey = "clawdis.canvasEnabled" let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index ac5bd9882..275198092 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -12,6 +12,7 @@ enum ControlRequestHandler { if paused { return Response(ok: false, message: "clawdis paused") } + let canvasEnabled = await MainActor.run { AppStateStore.canvasEnabled } switch request { case let .notify(title, body, sound, priority, delivery): @@ -83,6 +84,54 @@ enum ControlRequestHandler { return rpcResult.ok ? Response(ok: true, message: rpcResult.text ?? "sent") : Response(ok: false, message: rpcResult.error ?? "failed to send") + + case let .canvasShow(session, path): + guard canvasEnabled else { + return Response(ok: false, message: "Canvas disabled by user") + } + do { + let dir = try await MainActor.run { try CanvasManager.shared.show(sessionKey: session, path: path) } + return Response(ok: true, message: dir) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + + case let .canvasHide(session): + await MainActor.run { CanvasManager.shared.hide(sessionKey: session) } + return Response(ok: true) + + case let .canvasGoto(session, path): + guard canvasEnabled else { + return Response(ok: false, message: "Canvas disabled by user") + } + do { + try await MainActor.run { try CanvasManager.shared.goto(sessionKey: session, path: path) } + return Response(ok: true) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + + case let .canvasEval(session, javaScript): + guard canvasEnabled else { + return Response(ok: false, message: "Canvas disabled by user") + } + do { + let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript) + return Response(ok: true, payload: Data(result.utf8)) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + + case let .canvasSnapshot(session, outPath): + guard canvasEnabled else { + return Response(ok: false, message: "Canvas disabled by user") + } + do { + let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath) + return Response(ok: true, message: path) + } catch { + return Response(ok: false, message: error.localizedDescription) + } } } } diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 5166807f0..10f26edc5 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -7,6 +7,7 @@ struct DebugSettings: View { @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue + @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true @State private var modelsCount: Int? @State private var modelsLoading = false @State private var modelsError: String? @@ -25,6 +26,13 @@ struct DebugSettings: View { @AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false @AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false + @State private var canvasSessionKey: String = "main" + @State private var canvasStatus: String? + @State private var canvasError: String? + @State private var canvasEvalJS: String = "document.title" + @State private var canvasEvalResult: String? + @State private var canvasSnapshotPath: String? + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 10) { @@ -260,6 +268,84 @@ struct DebugSettings: View { } .buttonStyle(.bordered) Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Canvas") + .font(.caption.weight(.semibold)) + Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled) + .toggleStyle(.switch) + .help("When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.") + HStack(spacing: 8) { + TextField("Session", text: self.$canvasSessionKey) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .frame(width: 160) + Button("Show panel") { + Task { await self.canvasShow() } + } + .buttonStyle(.borderedProminent) + Button("Hide panel") { + CanvasManager.shared.hideAll() + self.canvasStatus = "hidden" + self.canvasError = nil + } + .buttonStyle(.bordered) + Button("Write sample page") { + Task { await self.canvasWriteSamplePage() } + } + .buttonStyle(.bordered) + } + HStack(spacing: 8) { + TextField("Eval JS", text: self.$canvasEvalJS) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .frame(maxWidth: 420) + Button("Eval") { + Task { await self.canvasEval() } + } + .buttonStyle(.bordered) + Button("Snapshot") { + Task { await self.canvasSnapshot() } + } + .buttonStyle(.bordered) + } + if let canvasStatus { + Text(canvasStatus) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let canvasEvalResult { + Text("eval → \(canvasEvalResult)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + if let canvasSnapshotPath { + HStack(spacing: 8) { + Text("snapshot → \(canvasSnapshotPath)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)]) + } + .buttonStyle(.bordered) + } + } + if let canvasError { + Text(canvasError) + .font(.caption2) + .foregroundStyle(.red) + } else { + Text("Tip: the session directory is returned by “Show panel”.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } LabeledContent("Icon override") { Picker("Icon override", selection: self.bindingOverride) { ForEach(IconOverrideSelection.allCases) { option in @@ -451,6 +537,117 @@ struct DebugSettings: View { .appendingPathComponent(".clawdis") .appendingPathComponent("clawdis.json") } + + // MARK: - Canvas debug actions + + @MainActor + private func canvasShow() async { + self.canvasError = nil + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") + self.canvasStatus = "dir: \(dir)" + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasWriteSamplePage() async { + self.canvasError = nil + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/") + let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false) + let now = ISO8601DateFormatter().string(from: Date()) + let html = """ + + + + + + Canvas Debug + + + +
+
+
Canvas Debug
+
generated: \(now)
+
userAgent:
+ +
count: 0
+
+
+
This is a local file served by the WKURLSchemeHandler.
+
+
+
+
+
+
+ + + + """ + try html.write(to: url, atomically: true, encoding: .utf8) + self.canvasStatus = "wrote: \(url.path)" + try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/") + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasEval() async { + self.canvasError = nil + self.canvasEvalResult = nil + do { + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let result = try await CanvasManager.shared.eval( + sessionKey: session.isEmpty ? "main" : session, + javaScript: self.canvasEvalJS) + self.canvasEvalResult = result + } catch { + self.canvasError = error.localizedDescription + } + } + + @MainActor + private func canvasSnapshot() async { + self.canvasError = nil + self.canvasSnapshotPath = nil + do { + let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + let path = try await CanvasManager.shared.snapshot( + sessionKey: session.isEmpty ? "main" : session, + outPath: nil) + self.canvasSnapshotPath = path + } catch { + self.canvasError = error.localizedDescription + } + } } #if DEBUG diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 750d0744c..801535414 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -79,6 +79,7 @@ struct ClawdisApp: App { self.isPanelVisible = visible self.updateStatusHighlight() } + CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() } let handler = StatusItemMouseHandlerView() handler.translatesAutoresizingMaskIntoConstraints = false diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index a3b78cab3..5d4f4d0d7 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -37,6 +37,14 @@ struct MenuContent: View { WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey()) } } + Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { + Text("Allow Canvas") + } + .onChange(of: self.state.canvasEnabled) { _, enabled in + if !enabled { + CanvasManager.shared.hideAll() + } + } Divider() Button("Settings…") { self.open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index f38f4cc7a..b013e4713 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -163,6 +163,80 @@ struct ClawdisCLI { guard let message else { throw CLIError.help } return .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to) + case "canvas": + guard let sub = args.first else { throw CLIError.help } + args = Array(args.dropFirst()) + + switch sub { + case "show": + var session = "main" + var path: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--path": path = args.popFirst() + default: break + } + } + return .canvasShow(session: session, path: path) + + case "hide": + var session = "main" + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + default: break + } + } + return .canvasHide(session: session) + + case "goto": + var session = "main" + var path: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--path": path = args.popFirst() + default: break + } + } + guard let path else { throw CLIError.help } + return .canvasGoto(session: session, path: path) + + case "eval": + var session = "main" + var js: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--js": js = args.popFirst() + default: break + } + } + guard let js else { throw CLIError.help } + return .canvasEval(session: session, javaScript: js) + + case "snapshot": + var session = "main" + var outPath: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--session": session = args.popFirst() ?? session + case "--out": outPath = args.popFirst() + default: break + } + } + return .canvasSnapshot(session: session, outPath: outPath) + + default: + throw CLIError.help + } + default: throw CLIError.help } @@ -185,6 +259,11 @@ struct ClawdisCLI { clawdis-mac rpc-status clawdis-mac agent --message [--thinking ] [--session ] [--deliver] [--to ] + clawdis-mac canvas show [--session ] [--path ] + clawdis-mac canvas hide [--session ] + clawdis-mac canvas goto --path [--session ] + clawdis-mac canvas eval --js [--session ] + clawdis-mac canvas snapshot [--out ] [--session ] clawdis-mac --help Returns JSON to stdout: diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 64036f06f..5b831a0e5 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -49,6 +49,11 @@ public enum Request: Sendable { case status case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?) case rpcStatus + case canvasShow(session: String, path: String?) + case canvasHide(session: String) + case canvasGoto(session: String, path: String) + case canvasEval(session: String, javaScript: String) + case canvasSnapshot(session: String, outPath: String?) } // MARK: - Responses @@ -77,6 +82,9 @@ extension Request: Codable { case command, cwd, env, timeoutSec, needsScreenRecording case message, thinking, session, deliver, to case rpcStatus + case path + case javaScript + case outPath } private enum Kind: String, Codable { @@ -87,6 +95,11 @@ extension Request: Codable { case status case agent case rpcStatus + case canvasShow + case canvasHide + case canvasGoto + case canvasEval + case canvasSnapshot } public func encode(to encoder: Encoder) throws { @@ -132,6 +145,30 @@ extension Request: Codable { case .rpcStatus: try container.encode(Kind.rpcStatus, forKey: .type) + + case let .canvasShow(session, path): + try container.encode(Kind.canvasShow, forKey: .type) + try container.encode(session, forKey: .session) + try container.encodeIfPresent(path, forKey: .path) + + case let .canvasHide(session): + try container.encode(Kind.canvasHide, forKey: .type) + try container.encode(session, forKey: .session) + + case let .canvasGoto(session, path): + try container.encode(Kind.canvasGoto, forKey: .type) + try container.encode(session, forKey: .session) + try container.encode(path, forKey: .path) + + case let .canvasEval(session, javaScript): + try container.encode(Kind.canvasEval, forKey: .type) + try container.encode(session, forKey: .session) + try container.encode(javaScript, forKey: .javaScript) + + case let .canvasSnapshot(session, outPath): + try container.encode(Kind.canvasSnapshot, forKey: .type) + try container.encode(session, forKey: .session) + try container.encodeIfPresent(outPath, forKey: .outPath) } } @@ -179,6 +216,30 @@ extension Request: Codable { case .rpcStatus: self = .rpcStatus + + case .canvasShow: + let session = try container.decode(String.self, forKey: .session) + let path = try container.decodeIfPresent(String.self, forKey: .path) + self = .canvasShow(session: session, path: path) + + case .canvasHide: + let session = try container.decode(String.self, forKey: .session) + self = .canvasHide(session: session) + + case .canvasGoto: + let session = try container.decode(String.self, forKey: .session) + let path = try container.decode(String.self, forKey: .path) + self = .canvasGoto(session: session, path: path) + + case .canvasEval: + let session = try container.decode(String.self, forKey: .session) + let javaScript = try container.decode(String.self, forKey: .javaScript) + self = .canvasEval(session: session, javaScript: javaScript) + + case .canvasSnapshot: + let session = try container.decode(String.self, forKey: .session) + let outPath = try container.decodeIfPresent(String.self, forKey: .outPath) + self = .canvasSnapshot(session: session, outPath: outPath) } } } diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md new file mode 100644 index 000000000..75158a974 --- /dev/null +++ b/docs/mac/canvas.md @@ -0,0 +1,92 @@ +--- +summary: "Agent-controlled Canvas panel embedded via WKWebView + custom URL scheme" +read_when: + - Implementing the macOS Canvas panel + - Adding agent controls for visual workspace + - Debugging WKWebView canvas loads +--- + +# Canvas (macOS app) + +Status: draft spec · Date: 2025-12-12 + +Clawdis can embed an agent-controlled “visual workspace” panel (“Canvas”) inside the macOS app using `WKWebView`, served via a **custom URL scheme** (no loopback HTTP port required). + +This is designed for: +- Agent-written HTML/CSS/JS on disk (per-session directory). +- A real browser engine for layout, rendering, and basic interactivity. +- Agent-driven visibility (show/hide), navigation, DOM/JS queries, and snapshots. +- Minimal chrome: borderless panel; bezel/chrome appears only on hover. + +## Why a custom scheme (vs. loopback HTTP) + +Using `WKURLSchemeHandler` keeps Canvas entirely in-process: +- No port conflicts and no extra local server lifecycle. +- Easier to sandbox: only serve files we explicitly map. +- Works offline and can use an ephemeral data store (no persistent cookies/cache). + +If a Canvas page truly needs “real web” semantics (CORS, fetch to loopback endpoints, service workers), consider the loopback-server variant instead (out of scope for this doc). + +## URL ↔ directory mapping + +The Canvas scheme is: +- `clawdis-canvas:///` + +Routing model: +- `clawdis-canvas://main/` → `/main/index.html` (or `index.htm`) +- `clawdis-canvas://main/yolo` → `/main/yolo/index.html` (or `index.htm`) +- `clawdis-canvas://main/assets/app.css` → `/main/assets/app.css` + +Directory listings are not served. + +When `/` has no `index.html` yet, the handler serves a built-in welcome page with: +- The resolved on-disk session directory path. +- A short “create index.html” hint. + +### Suggested on-disk location + +Store Canvas state under the app support directory: +- `~/Library/Application Support/Clawdis/canvas//…` + +This keeps it alongside other app-owned state and avoids mixing with `~/.clawdis/` gateway config. + +## Panel behavior (agent-controlled) + +Canvas is presented as a borderless `NSPanel` (similar to the existing WebChat panel): +- Can be shown/hidden at any time by the agent. +- Supports an “anchored” presentation (near the menu bar icon or another anchor rect). +- Uses a rounded container; shadow stays on, but **chrome/bezel only appears on hover**. + +### Hover-only chrome + +Implementation notes: +- Keep the window borderless at all times (don’t toggle `styleMask`). +- Add an overlay view inside the content container for chrome (stroke + subtle gradient/material). +- Use an `NSTrackingArea` to fade the chrome in/out on `mouseEntered/mouseExited`. +- Optionally show close/drag affordances only while hovered. + +## Agent API surface (proposed) + +Expose Canvas via the existing `clawdis-mac` → XPC → app routing so the agent can: +- Show/hide the panel. +- Navigate to a path (relative to the session root). +- Evaluate JavaScript and optionally return results. +- Query/modify DOM (helpers mirroring “dom query/all/attr/click/type/wait” patterns). +- Capture a snapshot image of the current canvas view. + +This should be modeled after `WebChatManager`/`WebChatWindowController` but targeting `clawdis-canvas://…` URLs. + +## Security / guardrails + +Recommended defaults: +- `WKWebsiteDataStore.nonPersistent()` for Canvas (ephemeral). +- Navigation policy: allow only `clawdis-canvas://…` (and optionally `about:blank`); open `http/https` externally. +- Scheme handler must prevent directory traversal: resolved file paths must stay under `//`. +- Disable or tightly scope any JS bridge; prefer query-string/bootstrap config over `window.webkit.messageHandlers` for sensitive data. + +## Debugging + +Suggested debugging hooks: +- Enable Web Inspector for Canvas builds (same approach as WebChat). +- Log scheme requests + resolution decisions to OSLog (subsystem `com.steipete.clawdis`, category `Canvas`). +- Provide a “copy canvas dir” action in debug settings to quickly reveal the session directory in Finder.