diff --git a/apps/macos/Sources/Clawdis/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/Clawdis/CanvasA2UIActionMessageHandler.swift new file mode 100644 index 000000000..c29f69a36 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasA2UIActionMessageHandler.swift @@ -0,0 +1,147 @@ +import AppKit +import ClawdisIPC +import ClawdisKit +import Foundation +import WebKit + +final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { + static let messageName = "clawdisCanvasA2UIAction" + + private let sessionKey: String + + init(sessionKey: String) { + self.sessionKey = sessionKey + super.init() + } + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == Self.messageName else { return } + + // Only accept actions from local Canvas content (not arbitrary web pages). + guard let webView = message.webView, let url = webView.url else { return } + if url.scheme == CanvasScheme.scheme { + // ok + } else if Self.isLocalNetworkCanvasURL(url) { + // ok + } else { + return + } + + let body: [String: Any] = { + if let dict = message.body as? [String: Any] { return dict } + if let dict = message.body as? [AnyHashable: Any] { + return dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + } + return [:] + }() + guard !body.isEmpty else { return } + + let userActionAny = body["userAction"] ?? body + let userAction: [String: Any] = { + if let dict = userActionAny as? [String: Any] { return dict } + if let dict = userActionAny as? [AnyHashable: Any] { + return dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + } + return [:] + }() + guard !userAction.isEmpty else { return } + + guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return } + let actionId = + (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? UUID().uuidString + + canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") + + let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "main" + let sourceComponentId = (userAction["sourceComponentId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" + let instanceId = InstanceIdentity.instanceId.lowercased() + let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"]) + + // Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas. + let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext( + actionName: name, + session: .init(key: self.sessionKey, surfaceId: surfaceId), + component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId), + contextJSON: contextJSON) + let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext) + + Task { [weak webView] in + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( + message: text, + sessionKey: self.sessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: .last, + idempotencyKey: actionId)) + + await MainActor.run { + guard let webView else { return } + let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus( + actionId: actionId, + ok: result.ok, + error: result.error) + webView.evaluateJavaScript(js) { _, _ in } + } + if !result.ok { + canvasWindowLogger.error( + """ + A2UI action send failed name=\(name, privacy: .public) \ + error=\(result.error ?? "unknown", privacy: .public) + """) + } + } + } + + static func isLocalNetworkCanvasURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return false + } + if host == "localhost" { return true } + if host.hasSuffix(".local") { return true } + if host.hasSuffix(".ts.net") { return true } + if host.hasSuffix(".tailscale.net") { return true } + if !host.contains("."), !host.contains(":") { return true } + if let ipv4 = Self.parseIPv4(host) { + return Self.isLocalNetworkIPv4(ipv4) + } + return false + } + + static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes: [UInt8] = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + if a == 10 { return true } + if a == 172, (16...31).contains(Int(b)) { return true } + if a == 192, b == 168 { return true } + if a == 127 { return true } + if a == 169, b == 254 { return true } + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } + + // Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`). +} diff --git a/apps/macos/Sources/Clawdis/CanvasChromeContainerView.swift b/apps/macos/Sources/Clawdis/CanvasChromeContainerView.swift new file mode 100644 index 000000000..89c19ef13 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasChromeContainerView.swift @@ -0,0 +1,225 @@ +import AppKit +import QuartzCore + +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 CanvasResizeHandleView: NSView { + private var startPoint: NSPoint = .zero + private var startFrame: NSRect = .zero + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } + + override func mouseDown(with event: NSEvent) { + guard let window else { return } + _ = window.makeFirstResponder(self) + self.startPoint = NSEvent.mouseLocation + self.startFrame = window.frame + super.mouseDown(with: event) + } + + override func mouseDragged(with _: NSEvent) { + guard let window else { return } + let current = NSEvent.mouseLocation + let dx = current.x - self.startPoint.x + let dy = current.y - self.startPoint.y + + var frame = self.startFrame + frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) + frame.origin.y += dy + frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) + + if let screen = window.screen { + frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame) + } + window.setFrame(frame, display: true) + } + } + + private final class CanvasChromeOverlayView: NSView { + var onClose: (() -> Void)? + + private let dragHandle = CanvasDragHandleView(frame: .zero) + private let resizeHandle = CanvasResizeHandleView(frame: .zero) + + private final class PassthroughVisualEffectView: NSVisualEffectView { + override func hitTest(_: NSPoint) -> NSView? { nil } + } + + private let closeBackground: NSVisualEffectView = { + let v = PassthroughVisualEffectView(frame: .zero) + v.material = .hudWindow + v.blendingMode = .withinWindow + v.state = .active + v.appearance = NSAppearance(named: .vibrantDark) + v.wantsLayer = true + v.layer?.cornerRadius = 10 + v.layer?.masksToBounds = true + v.layer?.borderWidth = 1 + v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor + v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor + v.layer?.shadowOpacity = 0.35 + v.layer?.shadowRadius = 8 + v.layer?.shadowOffset = .zero + return v + }() + + private let closeButton: NSButton = { + let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold) + let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? + .withSymbolConfiguration(cfg) + ?? 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.white.withAlphaComponent(0.92) + 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.resizeHandle.translatesAutoresizingMaskIntoConstraints = false + self.resizeHandle.wantsLayer = true + self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor + self.addSubview(self.resizeHandle) + + self.closeBackground.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.closeBackground) + + 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.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor), + self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor), + self.closeBackground.widthAnchor.constraint(equalToConstant: 20), + self.closeBackground.heightAnchor.constraint(equalToConstant: 20), + + self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), + self.closeButton.widthAnchor.constraint(equalToConstant: 16), + self.closeButton.heightAnchor.constraint(equalToConstant: 16), + + self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), + self.resizeHandle.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 } + if self.resizeHandle.frame.contains(point) { return self.resizeHandle } + 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 + } + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index dcc994a6c..30b0c8c41 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -1,14 +1,9 @@ import AppKit -import ClawdisIPC -import ClawdisKit -import Foundation import OSLog -import QuartzCore -import WebKit -private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") +let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas") -private enum CanvasLayout { +enum CanvasLayout { static let panelSize = NSSize(width: 520, height: 680) static let windowSize = NSSize(width: 1120, height: 840) static let anchorPadding: CGFloat = 8 @@ -30,1022 +25,3 @@ enum CanvasPresentation { 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 var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler? - private let watcher: CanvasFileWatcher - private let container: HoverChromeContainerView - let presentation: CanvasPresentation - private var preferredPlacement: CanvasPlacement? - private(set) var currentTarget: String? - private var debugStatusEnabled = false - private var debugStatusTitle: String? - private var debugStatusSubtitle: String? - - var onVisibilityChanged: ((Bool) -> Void)? - - init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws { - self.sessionKey = sessionKey - self.root = root - self.presentation = presentation - - canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)") - let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) - canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") - self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) - try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true) - canvasWindowLogger.debug("CanvasWindowController init session dir ready") - - self.schemeHandler = CanvasSchemeHandler(root: root) - canvasWindowLogger.debug("CanvasWindowController init scheme handler ready") - - let config = WKWebViewConfiguration() - config.userContentController = WKUserContentController() - config.preferences.isElementFullscreenEnabled = true - config.preferences.setValue(true, forKey: "developerExtrasEnabled") - canvasWindowLogger.debug("CanvasWindowController init config ready") - config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme) - canvasWindowLogger.debug("CanvasWindowController init scheme handler installed") - - // Bridge A2UI "a2uiaction" DOM events back into the native agent loop. - // - // Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link - // (includes the app-generated key so it won't prompt). - canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script") - let deepLinkKey = DeepLinkHandler.currentCanvasKey() - let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main" - let bridgeScript = """ - (() => { - try { - if (location.protocol !== '\(CanvasScheme.scheme):') return; - if (globalThis.__clawdisA2UIBridgeInstalled) return; - globalThis.__clawdisA2UIBridgeInstalled = true; - - const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey)); - const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); - const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); - const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); - - globalThis.addEventListener('a2uiaction', (evt) => { - try { - const payload = evt?.detail ?? evt?.payload ?? null; - if (!payload || payload.eventType !== 'a2ui.action') return; - - const action = payload.action ?? null; - const name = action?.name ?? ''; - if (!name) return; - - const context = Array.isArray(action?.context) ? action.context : []; - const userAction = { - id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())), - name, - surfaceId: payload.surfaceId ?? 'main', - sourceComponentId: payload.sourceComponentId ?? '', - dataContextPath: payload.dataContextPath ?? '', - timestamp: new Date().toISOString(), - ...(context.length ? { context } : {}), - }; - - const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction; - - // If the bundled A2UI shell is present, let it forward actions so we keep its richer - // context resolution (data model path lookups, surface detection, etc.). - const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host'); - if (hasBundledA2UIHost && handler?.postMessage) return; - - // Otherwise, forward directly when possible. - if (!hasBundledA2UIHost && handler?.postMessage) { - handler.postMessage({ userAction }); - return; - } - - const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; - const message = - 'CANVAS_A2UI action=' + userAction.name + - ' session=' + sessionKey + - ' surface=' + userAction.surfaceId + - ' component=' + (userAction.sourceComponentId || '-') + - ' host=' + machineName.replace(/\\s+/g, '_') + - ' instance=' + instanceId + - ctx + - ' default=update_canvas'; - const params = new URLSearchParams(); - params.set('message', message); - params.set('sessionKey', sessionKey); - params.set('thinking', 'low'); - params.set('deliver', 'false'); - params.set('channel', 'last'); - params.set('key', deepLinkKey); - location.href = 'clawdis://agent?' + params.toString(); - } catch {} - }, true); - } catch {} - })(); - """ - config.userContentController.addUserScript( - WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) - canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed") - - canvasWindowLogger.debug("CanvasWindowController init creating WKWebView") - self.webView = WKWebView(frame: .zero, configuration: config) - // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. - self.webView.setValue(true, forKey: "drawsBackground") - - let sessionDir = self.sessionDir - let webView = self.webView - self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in - Task { @MainActor in - guard let webView else { return } - - // Only auto-reload when we are showing local canvas content. - guard webView.url?.scheme == CanvasScheme.scheme else { return } - - let path = webView.url?.path ?? "" - if path == "/" || path.isEmpty { - let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) - let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) - if !FileManager.default.fileExists(atPath: indexA.path), - !FileManager.default.fileExists(atPath: indexB.path) - { - return - } - } - - webView.reload() - } - } - - self.container = HoverChromeContainerView(containing: self.webView) - let window = Self.makeWindow(for: presentation, contentView: self.container) - canvasWindowLogger.debug("CanvasWindowController init makeWindow done") - super.init(window: window) - - let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey) - self.a2uiActionMessageHandler = handler - self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName) - - self.webView.navigationDelegate = self - self.window?.delegate = self - self.container.onClose = { [weak self] in - self?.hideCanvas() - } - - self.watcher.start() - canvasWindowLogger.debug("CanvasWindowController init done") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - @MainActor deinit { - self.webView.configuration.userContentController - .removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName) - self.watcher.stop() - } - - func applyPreferredPlacement(_ placement: CanvasPlacement?) { - self.preferredPlacement = placement - } - - func showCanvas(path: String? = nil) { - if case let .panel(anchorProvider) = self.presentation { - self.presentAnchoredPanel(anchorProvider: anchorProvider) - if let path { - self.load(target: path) - } - return - } - - self.showWindow(nil) - self.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - if let path { - self.load(target: path) - } - self.onVisibilityChanged?(true) - } - - func hideCanvas() { - if case .panel = self.presentation { - self.persistFrameIfPanel() - } - self.window?.orderOut(nil) - self.onVisibilityChanged?(false) - } - - func load(target: String) { - let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) - self.currentTarget = trimmed - - if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { - if scheme == "https" || scheme == "http" { - canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)") - self.webView.load(URLRequest(url: url)) - return - } - if scheme == "file" { - canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") - self.loadFile(url) - return - } - } - - // Convenience: absolute file paths resolve as local files when they exist. - // (Avoid treating Canvas routes like "/" as filesystem paths.) - if trimmed.hasPrefix("/") { - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { - let url = URL(fileURLWithPath: trimmed) - canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") - self.loadFile(url) - return - } - } - - guard let url = CanvasScheme.makeURL( - session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), - path: trimmed) - else { - canvasWindowLogger - .error( - "invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") - return - } - canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)") - self.webView.load(URLRequest(url: url)) - } - - func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) { - self.debugStatusEnabled = enabled - self.debugStatusTitle = title - self.debugStatusSubtitle = subtitle - self.applyDebugStatusIfNeeded() - } - - private func applyDebugStatusIfNeeded() { - let enabled = self.debugStatusEnabled - let title = Self.jsOptionalStringLiteral(self.debugStatusTitle) - let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle) - let js = """ - (() => { - try { - const api = globalThis.__clawdis; - if (!api) return; - if (typeof api.setDebugStatusEnabled === 'function') { - api.setDebugStatusEnabled(\(enabled ? "true" : "false")); - } - if (!\(enabled ? "true" : "false")) return; - if (typeof api.setStatus === 'function') { - api.setStatus(\(title), \(subtitle)); - } - } catch (_) {} - })(); - """ - self.webView.evaluateJavaScript(js) { _, _ in } - } - - private func loadFile(_ url: URL) { - let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path) - let accessDir = fileURL.deletingLastPathComponent() - self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir) - } - - func eval(javaScript: String) async throws -> String { - try await withCheckedThrowingContinuation { cont in - self.webView.evaluateJavaScript(javaScript) { result, error in - if let error { - cont.resume(throwing: error) - 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 - } - - func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool { - let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty || trimmed == "/" { return true } - if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines), - !lastAuto.isEmpty, - trimmed == lastAuto - { - return true - } - return false - } - - // 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.isReleasedWhenClosed = false - 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, .resizable], - 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 - panel.minSize = CanvasLayout.minPanelSize - 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) - VoiceWakeOverlayController.shared.bringToFrontIfVisible() - self.onVisibilityChanged?(true) - } - - private func repositionPanel(using anchorProvider: () -> NSRect?) { - guard let panel = self.window else { return } - let anchor = anchorProvider() - let targetScreen = Self.screen(forAnchor: anchor) - ?? Self.screenContainingMouseCursor() - ?? panel.screen - ?? NSScreen.main - ?? NSScreen.screens.first - - let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) - let restoredIsValid = if let restored, let targetScreen { - Self.isFrameMeaningfullyVisible(restored, on: targetScreen) - } else { - restored != nil - } - - var frame = if let restored, restoredIsValid { - restored - } else { - Self.defaultTopRightFrame(panel: panel, screen: targetScreen) - } - - // Apply agent placement as partial overrides: - // - If agent provides x/y, override origin. - // - If agent provides width/height, override size. - // - If agent provides only size, keep the remembered origin. - if let placement = self.preferredPlacement { - if let x = placement.x { frame.origin.x = x } - if let y = placement.y { frame.origin.y = y } - if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) } - if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) } - } - - self.setPanelFrame(frame, on: targetScreen) - } - - private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect { - let w = max(CanvasLayout.minPanelSize.width, panel.frame.width) - let h = max(CanvasLayout.minPanelSize.height, panel.frame.height) - return WindowPlacement.topRightFrame( - size: NSSize(width: w, height: h), - padding: CanvasLayout.defaultPadding, - on: screen) - } - - private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { - guard let panel = self.window else { return } - guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { - panel.setFrame(frame, display: false) - self.persistFrameIfPanel() - return - } - - let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame) - panel.setFrame(constrained, display: false) - self.persistFrameIfPanel() - } - - private static func screen(forAnchor anchor: NSRect?) -> NSScreen? { - guard let anchor else { return nil } - let center = NSPoint(x: anchor.midX, y: anchor.midY) - return NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(center) - } - } - - private static func screenContainingMouseCursor() -> NSScreen? { - let point = NSEvent.mouseLocation - return NSScreen.screens.first { $0.frame.contains(point) } - } - - private static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool { - frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) - } - - fileprivate static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect { - if bounds == .zero { return frame } - - var next = frame - next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width) - next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height) - - let maxX = bounds.maxX - next.size.width - let maxY = bounds.maxY - next.size.height - - next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX - next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY - - next.origin.x = round(next.origin.x) - next.origin.y = round(next.origin.y) - return next - } - - // 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 - } - let scheme = url.scheme?.lowercased() - - // Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace. - if scheme == "clawdis" { - if self.webView.url?.scheme == CanvasScheme.scheme { - Task { await DeepLinkHandler.shared.handle(url: url) } - } else { - canvasWindowLogger - .debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)") - } - decisionHandler(.cancel) - return - } - - // Keep web content inside the panel when reasonable. - // `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace. - if scheme == CanvasScheme.scheme - || scheme == "https" - || scheme == "http" - || scheme == "about" - || scheme == "blob" - || scheme == "data" - || scheme == "javascript" - { - decisionHandler(.allow) - return - } - - // Only open external URLs when there is a registered handler, otherwise macOS will show a confusing - // "There is no application set to open the URL ..." alert (e.g. for about:blank). - if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) { - NSWorkspace.shared.open( - [url], - withApplicationAt: appURL, - configuration: NSWorkspace.OpenConfiguration(), - completionHandler: nil) - } else { - canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)") - } - decisionHandler(.cancel) - } - - func webView(_: WKWebView, didFinish _: WKNavigation?) { - self.applyDebugStatusIfNeeded() - } - - // MARK: - NSWindowDelegate - - func windowWillClose(_: Notification) { - self.onVisibilityChanged?(false) - } - - func windowDidMove(_: Notification) { - self.persistFrameIfPanel() - } - - func windowDidEndLiveResize(_: Notification) { - self.persistFrameIfPanel() - } - - private func persistFrameIfPanel() { - guard case .panel = self.presentation, let window else { return } - Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey) - } - - // 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) - } - - private static func jsStringLiteral(_ value: String) -> String { - let data = try? JSONEncoder().encode(value) - return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\"" - } - - private static func jsOptionalStringLiteral(_ value: String?) -> String { - guard let value else { return "null" } - return Self.jsStringLiteral(value) - } - - private static func storedFrameDefaultsKey(sessionKey: String) -> String { - "clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))" - } - - private static func loadRestoredFrame(sessionKey: String) -> NSRect? { - let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } - let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) - if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } - return rect - } - - private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { - let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) - UserDefaults.standard.set( - [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], - forKey: key) - } -} - -private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { - static let messageName = "clawdisCanvasA2UIAction" - - private let sessionKey: String - - init(sessionKey: String) { - self.sessionKey = sessionKey - super.init() - } - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - guard message.name == Self.messageName else { return } - - // Only accept actions from local Canvas content (not arbitrary web pages). - guard let webView = message.webView, let url = webView.url else { return } - if url.scheme == CanvasScheme.scheme { - // ok - } else if Self.isLocalNetworkCanvasURL(url) { - // ok - } else { - return - } - - let body: [String: Any] = { - if let dict = message.body as? [String: Any] { return dict } - if let dict = message.body as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !body.isEmpty else { return } - - let userActionAny = body["userAction"] ?? body - let userAction: [String: Any] = { - if let dict = userActionAny as? [String: Any] { return dict } - if let dict = userActionAny as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !userAction.isEmpty else { return } - - guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return } - let actionId = - (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - ?? UUID().uuidString - - canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)") - - let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - .nonEmpty ?? "main" - let sourceComponentId = (userAction["sourceComponentId"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-" - let instanceId = InstanceIdentity.instanceId.lowercased() - let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"]) - - // Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas. - let messageContext = ClawdisCanvasA2UIAction.AgentMessageContext( - actionName: name, - session: .init(key: self.sessionKey, surfaceId: surfaceId), - component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId), - contextJSON: contextJSON) - let text = ClawdisCanvasA2UIAction.formatAgentMessage(messageContext) - - Task { [weak webView] in - if AppStateStore.shared.connectionMode == .local { - GatewayProcessManager.shared.setActive(true) - } - - let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation( - message: text, - sessionKey: self.sessionKey, - thinking: "low", - deliver: false, - to: nil, - channel: .last, - idempotencyKey: actionId)) - - await MainActor.run { - guard let webView else { return } - let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus( - actionId: actionId, - ok: result.ok, - error: result.error) - webView.evaluateJavaScript(js) { _, _ in } - } - if !result.ok { - canvasWindowLogger.error( - """ - A2UI action send failed name=\(name, privacy: .public) \ - error=\(result.error ?? "unknown", privacy: .public) - """) - } - } - } - - fileprivate static func isLocalNetworkCanvasURL(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { - return false - } - guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { - return false - } - if host == "localhost" { return true } - if host.hasSuffix(".local") { return true } - if host.hasSuffix(".ts.net") { return true } - if host.hasSuffix(".tailscale.net") { return true } - if !host.contains("."), !host.contains(":") { return true } - if let ipv4 = Self.parseIPv4(host) { - return Self.isLocalNetworkIPv4(ipv4) - } - return false - } - - fileprivate static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { return nil } - let bytes: [UInt8] = parts.compactMap { UInt8($0) } - guard bytes.count == 4 else { return nil } - return (bytes[0], bytes[1], bytes[2], bytes[3]) - } - - fileprivate static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - let (a, b, _, _) = ip - if a == 10 { return true } - if a == 172, (16...31).contains(Int(b)) { return true } - if a == 192, b == 168 { return true } - if a == 127 { return true } - if a == 169, b == 254 { return true } - if a == 100, (64...127).contains(Int(b)) { return true } - return false - } - - // Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`). -} - -// 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 CanvasResizeHandleView: NSView { - private var startPoint: NSPoint = .zero - private var startFrame: NSRect = .zero - - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } - - override func mouseDown(with event: NSEvent) { - guard let window else { return } - _ = window.makeFirstResponder(self) - self.startPoint = NSEvent.mouseLocation - self.startFrame = window.frame - super.mouseDown(with: event) - } - - override func mouseDragged(with _: NSEvent) { - guard let window else { return } - let current = NSEvent.mouseLocation - let dx = current.x - self.startPoint.x - let dy = current.y - self.startPoint.y - - var frame = self.startFrame - frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx) - frame.origin.y += dy - frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy) - - if let screen = window.screen { - frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame) - } - window.setFrame(frame, display: true) - } - } - - private final class CanvasChromeOverlayView: NSView { - var onClose: (() -> Void)? - - private let dragHandle = CanvasDragHandleView(frame: .zero) - private let resizeHandle = CanvasResizeHandleView(frame: .zero) - - private final class PassthroughVisualEffectView: NSVisualEffectView { - override func hitTest(_: NSPoint) -> NSView? { nil } - } - - private let closeBackground: NSVisualEffectView = { - let v = PassthroughVisualEffectView(frame: .zero) - v.material = .hudWindow - v.blendingMode = .withinWindow - v.state = .active - v.appearance = NSAppearance(named: .vibrantDark) - v.wantsLayer = true - v.layer?.cornerRadius = 10 - v.layer?.masksToBounds = true - v.layer?.borderWidth = 1 - v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor - v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor - v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor - v.layer?.shadowOpacity = 0.35 - v.layer?.shadowRadius = 8 - v.layer?.shadowOffset = .zero - return v - }() - - private let closeButton: NSButton = { - let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold) - let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")? - .withSymbolConfiguration(cfg) - ?? 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.white.withAlphaComponent(0.92) - 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.resizeHandle.translatesAutoresizingMaskIntoConstraints = false - self.resizeHandle.wantsLayer = true - self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor - self.addSubview(self.resizeHandle) - - self.closeBackground.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.closeBackground) - - 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.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor), - self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor), - self.closeBackground.widthAnchor.constraint(equalToConstant: 20), - self.closeBackground.heightAnchor.constraint(equalToConstant: 20), - - self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), - self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), - self.closeButton.widthAnchor.constraint(equalToConstant: 16), - self.closeButton.heightAnchor.constraint(equalToConstant: 16), - - self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor), - self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor), - self.resizeHandle.widthAnchor.constraint(equalToConstant: 18), - self.resizeHandle.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 } - if self.resizeHandle.frame.contains(point) { return self.resizeHandle } - 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 - } - } -} - -#if DEBUG -extension CanvasWindowController { - static func _testSanitizeSessionKey(_ key: String) -> String { - self.sanitizeSessionKey(key) - } - - static func _testJSStringLiteral(_ value: String) -> String { - self.jsStringLiteral(value) - } - - static func _testJSOptionalStringLiteral(_ value: String?) -> String { - self.jsOptionalStringLiteral(value) - } - - static func _testStoredFrameKey(sessionKey: String) -> String { - self.storedFrameDefaultsKey(sessionKey: sessionKey) - } - - static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? { - self.storeRestoredFrame(frame, sessionKey: sessionKey) - return self.loadRestoredFrame(sessionKey: sessionKey) - } - - static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { - CanvasA2UIActionMessageHandler.parseIPv4(host) - } - - static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip) - } - - static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool { - CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url) - } -} -#endif diff --git a/apps/macos/Sources/Clawdis/CanvasWindowController+Helpers.swift b/apps/macos/Sources/Clawdis/CanvasWindowController+Helpers.swift new file mode 100644 index 000000000..97eade319 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindowController+Helpers.swift @@ -0,0 +1,43 @@ +import AppKit +import Foundation + +extension CanvasWindowController { + // MARK: - Helpers + + 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) + } + + static func jsStringLiteral(_ value: String) -> String { + let data = try? JSONEncoder().encode(value) + return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\"" + } + + static func jsOptionalStringLiteral(_ value: String?) -> String { + guard let value else { return "null" } + return Self.jsStringLiteral(value) + } + + static func storedFrameDefaultsKey(sessionKey: String) -> String { + "clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))" + } + + static func loadRestoredFrame(sessionKey: String) -> NSRect? { + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) + guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil } + let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3]) + if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil } + return rect + } + + static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) { + let key = self.storedFrameDefaultsKey(sessionKey: sessionKey) + UserDefaults.standard.set( + [Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], + forKey: key) + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasWindowController+Navigation.swift b/apps/macos/Sources/Clawdis/CanvasWindowController+Navigation.swift new file mode 100644 index 000000000..ae4f41819 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindowController+Navigation.swift @@ -0,0 +1,62 @@ +import AppKit +import WebKit + +extension CanvasWindowController { + // 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 + } + let scheme = url.scheme?.lowercased() + + // Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace. + if scheme == "clawdis" { + if self.webView.url?.scheme == CanvasScheme.scheme { + Task { await DeepLinkHandler.shared.handle(url: url) } + } else { + canvasWindowLogger + .debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)") + } + decisionHandler(.cancel) + return + } + + // Keep web content inside the panel when reasonable. + // `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace. + if scheme == CanvasScheme.scheme + || scheme == "https" + || scheme == "http" + || scheme == "about" + || scheme == "blob" + || scheme == "data" + || scheme == "javascript" + { + decisionHandler(.allow) + return + } + + // Only open external URLs when there is a registered handler, otherwise macOS will show a confusing + // "There is no application set to open the URL ..." alert (e.g. for about:blank). + if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) { + NSWorkspace.shared.open( + [url], + withApplicationAt: appURL, + configuration: NSWorkspace.OpenConfiguration(), + completionHandler: nil) + } else { + canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)") + } + decisionHandler(.cancel) + } + + func webView(_: WKWebView, didFinish _: WKNavigation?) { + self.applyDebugStatusIfNeeded() + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasWindowController+Testing.swift b/apps/macos/Sources/Clawdis/CanvasWindowController+Testing.swift new file mode 100644 index 000000000..6c53fbc99 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindowController+Testing.swift @@ -0,0 +1,39 @@ +#if DEBUG +import AppKit +import Foundation + +extension CanvasWindowController { + static func _testSanitizeSessionKey(_ key: String) -> String { + self.sanitizeSessionKey(key) + } + + static func _testJSStringLiteral(_ value: String) -> String { + self.jsStringLiteral(value) + } + + static func _testJSOptionalStringLiteral(_ value: String?) -> String { + self.jsOptionalStringLiteral(value) + } + + static func _testStoredFrameKey(sessionKey: String) -> String { + self.storedFrameDefaultsKey(sessionKey: sessionKey) + } + + static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? { + self.storeRestoredFrame(frame, sessionKey: sessionKey) + return self.loadRestoredFrame(sessionKey: sessionKey) + } + + static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + CanvasA2UIActionMessageHandler.parseIPv4(host) + } + + static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip) + } + + static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool { + CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/CanvasWindowController+Window.swift b/apps/macos/Sources/Clawdis/CanvasWindowController+Window.swift new file mode 100644 index 000000000..e1f93de8a --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindowController+Window.swift @@ -0,0 +1,166 @@ +import AppKit +import ClawdisIPC + +extension CanvasWindowController { + // MARK: - Window + + 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.isReleasedWhenClosed = false + 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, .resizable], + 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 + panel.minSize = CanvasLayout.minPanelSize + 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) + VoiceWakeOverlayController.shared.bringToFrontIfVisible() + self.onVisibilityChanged?(true) + } + + func repositionPanel(using anchorProvider: () -> NSRect?) { + guard let panel = self.window else { return } + let anchor = anchorProvider() + let targetScreen = Self.screen(forAnchor: anchor) + ?? Self.screenContainingMouseCursor() + ?? panel.screen + ?? NSScreen.main + ?? NSScreen.screens.first + + let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) + let restoredIsValid = if let restored, let targetScreen { + Self.isFrameMeaningfullyVisible(restored, on: targetScreen) + } else { + restored != nil + } + + var frame = if let restored, restoredIsValid { + restored + } else { + Self.defaultTopRightFrame(panel: panel, screen: targetScreen) + } + + // Apply agent placement as partial overrides: + // - If agent provides x/y, override origin. + // - If agent provides width/height, override size. + // - If agent provides only size, keep the remembered origin. + if let placement = self.preferredPlacement { + if let x = placement.x { frame.origin.x = x } + if let y = placement.y { frame.origin.y = y } + if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) } + if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) } + } + + self.setPanelFrame(frame, on: targetScreen) + } + + static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect { + let w = max(CanvasLayout.minPanelSize.width, panel.frame.width) + let h = max(CanvasLayout.minPanelSize.height, panel.frame.height) + return WindowPlacement.topRightFrame( + size: NSSize(width: w, height: h), + padding: CanvasLayout.defaultPadding, + on: screen) + } + + func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) { + guard let panel = self.window else { return } + guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else { + panel.setFrame(frame, display: false) + self.persistFrameIfPanel() + return + } + + let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame) + panel.setFrame(constrained, display: false) + self.persistFrameIfPanel() + } + + static func screen(forAnchor anchor: NSRect?) -> NSScreen? { + guard let anchor else { return nil } + let center = NSPoint(x: anchor.midX, y: anchor.midY) + return NSScreen.screens.first { screen in + screen.frame.contains(anchor.origin) || screen.frame.contains(center) + } + } + + static func screenContainingMouseCursor() -> NSScreen? { + let point = NSEvent.mouseLocation + return NSScreen.screens.first { $0.frame.contains(point) } + } + + static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool { + frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12)) + } + + static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect { + if bounds == .zero { return frame } + + var next = frame + next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width) + next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height) + + let maxX = bounds.maxX - next.size.width + let maxY = bounds.maxY - next.size.height + + next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX + next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY + + next.origin.x = round(next.origin.x) + next.origin.y = round(next.origin.y) + return next + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_: Notification) { + self.onVisibilityChanged?(false) + } + + func windowDidMove(_: Notification) { + self.persistFrameIfPanel() + } + + func windowDidEndLiveResize(_: Notification) { + self.persistFrameIfPanel() + } + + func persistFrameIfPanel() { + guard case .panel = self.presentation, let window else { return } + Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey) + } +} diff --git a/apps/macos/Sources/Clawdis/CanvasWindowController.swift b/apps/macos/Sources/Clawdis/CanvasWindowController.swift new file mode 100644 index 000000000..6e2cc0037 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CanvasWindowController.swift @@ -0,0 +1,361 @@ +import AppKit +import ClawdisIPC +import ClawdisKit +import Foundation +import WebKit + +@MainActor +final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate { + let sessionKey: String + private let root: URL + private let sessionDir: URL + private let schemeHandler: CanvasSchemeHandler + let webView: WKWebView + private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler? + private let watcher: CanvasFileWatcher + private let container: HoverChromeContainerView + let presentation: CanvasPresentation + var preferredPlacement: CanvasPlacement? + private(set) var currentTarget: String? + private var debugStatusEnabled = false + private var debugStatusTitle: String? + private var debugStatusSubtitle: String? + + var onVisibilityChanged: ((Bool) -> Void)? + + init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws { + self.sessionKey = sessionKey + self.root = root + self.presentation = presentation + + canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)") + let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) + canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") + self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) + try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true) + canvasWindowLogger.debug("CanvasWindowController init session dir ready") + + self.schemeHandler = CanvasSchemeHandler(root: root) + canvasWindowLogger.debug("CanvasWindowController init scheme handler ready") + + let config = WKWebViewConfiguration() + config.userContentController = WKUserContentController() + config.preferences.isElementFullscreenEnabled = true + config.preferences.setValue(true, forKey: "developerExtrasEnabled") + canvasWindowLogger.debug("CanvasWindowController init config ready") + config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme) + canvasWindowLogger.debug("CanvasWindowController init scheme handler installed") + + // Bridge A2UI "a2uiaction" DOM events back into the native agent loop. + // + // Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link + // (includes the app-generated key so it won't prompt). + canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script") + let deepLinkKey = DeepLinkHandler.currentCanvasKey() + let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main" + let bridgeScript = """ + (() => { + try { + if (location.protocol !== '\(CanvasScheme.scheme):') return; + if (globalThis.__clawdisA2UIBridgeInstalled) return; + globalThis.__clawdisA2UIBridgeInstalled = true; + + const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey)); + const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); + const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); + const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); + + globalThis.addEventListener('a2uiaction', (evt) => { + try { + const payload = evt?.detail ?? evt?.payload ?? null; + if (!payload || payload.eventType !== 'a2ui.action') return; + + const action = payload.action ?? null; + const name = action?.name ?? ''; + if (!name) return; + + const context = Array.isArray(action?.context) ? action.context : []; + const userAction = { + id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())), + name, + surfaceId: payload.surfaceId ?? 'main', + sourceComponentId: payload.sourceComponentId ?? '', + dataContextPath: payload.dataContextPath ?? '', + timestamp: new Date().toISOString(), + ...(context.length ? { context } : {}), + }; + + const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction; + + // If the bundled A2UI shell is present, let it forward actions so we keep its richer + // context resolution (data model path lookups, surface detection, etc.). + const hasBundledA2UIHost = !!globalThis.clawdisA2UI || !!document.querySelector('clawdis-a2ui-host'); + if (hasBundledA2UIHost && handler?.postMessage) return; + + // Otherwise, forward directly when possible. + if (!hasBundledA2UIHost && handler?.postMessage) { + handler.postMessage({ userAction }); + return; + } + + const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; + const message = + 'CANVAS_A2UI action=' + userAction.name + + ' session=' + sessionKey + + ' surface=' + userAction.surfaceId + + ' component=' + (userAction.sourceComponentId || '-') + + ' host=' + machineName.replace(/\\s+/g, '_') + + ' instance=' + instanceId + + ctx + + ' default=update_canvas'; + const params = new URLSearchParams(); + params.set('message', message); + params.set('sessionKey', sessionKey); + params.set('thinking', 'low'); + params.set('deliver', 'false'); + params.set('channel', 'last'); + params.set('key', deepLinkKey); + location.href = 'clawdis://agent?' + params.toString(); + } catch {} + }, true); + } catch {} + })(); + """ + config.userContentController.addUserScript( + WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed") + + canvasWindowLogger.debug("CanvasWindowController init creating WKWebView") + self.webView = WKWebView(frame: .zero, configuration: config) + // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. + self.webView.setValue(true, forKey: "drawsBackground") + + let sessionDir = self.sessionDir + let webView = self.webView + self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in + Task { @MainActor in + guard let webView else { return } + + // Only auto-reload when we are showing local canvas content. + guard webView.url?.scheme == CanvasScheme.scheme else { return } + + let path = webView.url?.path ?? "" + if path == "/" || path.isEmpty { + let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) + let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) + if !FileManager.default.fileExists(atPath: indexA.path), + !FileManager.default.fileExists(atPath: indexB.path) + { + return + } + } + + webView.reload() + } + } + + self.container = HoverChromeContainerView(containing: self.webView) + let window = Self.makeWindow(for: presentation, contentView: self.container) + canvasWindowLogger.debug("CanvasWindowController init makeWindow done") + super.init(window: window) + + let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey) + self.a2uiActionMessageHandler = handler + self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName) + + self.webView.navigationDelegate = self + self.window?.delegate = self + self.container.onClose = { [weak self] in + self?.hideCanvas() + } + + self.watcher.start() + canvasWindowLogger.debug("CanvasWindowController init done") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + @MainActor deinit { + self.webView.configuration.userContentController + .removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName) + self.watcher.stop() + } + + func applyPreferredPlacement(_ placement: CanvasPlacement?) { + self.preferredPlacement = placement + } + + func showCanvas(path: String? = nil) { + if case let .panel(anchorProvider) = self.presentation { + self.presentAnchoredPanel(anchorProvider: anchorProvider) + if let path { + self.load(target: path) + } + return + } + + self.showWindow(nil) + self.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + if let path { + self.load(target: path) + } + self.onVisibilityChanged?(true) + } + + func hideCanvas() { + if case .panel = self.presentation { + self.persistFrameIfPanel() + } + self.window?.orderOut(nil) + self.onVisibilityChanged?(false) + } + + func load(target: String) { + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + self.currentTarget = trimmed + + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { + if scheme == "https" || scheme == "http" { + canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)") + self.webView.load(URLRequest(url: url)) + return + } + if scheme == "file" { + canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") + self.loadFile(url) + return + } + } + + // Convenience: absolute file paths resolve as local files when they exist. + // (Avoid treating Canvas routes like "/" as filesystem paths.) + if trimmed.hasPrefix("/") { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + let url = URL(fileURLWithPath: trimmed) + canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") + self.loadFile(url) + return + } + } + + guard let url = CanvasScheme.makeURL( + session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), + path: trimmed) + else { + canvasWindowLogger + .error( + "invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)") + return + } + canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)") + self.webView.load(URLRequest(url: url)) + } + + func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) { + self.debugStatusEnabled = enabled + self.debugStatusTitle = title + self.debugStatusSubtitle = subtitle + self.applyDebugStatusIfNeeded() + } + + func applyDebugStatusIfNeeded() { + let enabled = self.debugStatusEnabled + let title = Self.jsOptionalStringLiteral(self.debugStatusTitle) + let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle) + let js = """ + (() => { + try { + const api = globalThis.__clawdis; + if (!api) return; + if (typeof api.setDebugStatusEnabled === 'function') { + api.setDebugStatusEnabled(\(enabled ? "true" : "false")); + } + if (!\(enabled ? "true" : "false")) return; + if (typeof api.setStatus === 'function') { + api.setStatus(\(title), \(subtitle)); + } + } catch (_) {} + })(); + """ + self.webView.evaluateJavaScript(js) { _, _ in } + } + + private func loadFile(_ url: URL) { + let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path) + let accessDir = fileURL.deletingLastPathComponent() + self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir) + } + + func eval(javaScript: String) async throws -> String { + try await withCheckedThrowingContinuation { cont in + self.webView.evaluateJavaScript(javaScript) { result, error in + if let error { + cont.resume(throwing: error) + 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 + } + + func shouldAutoNavigateToA2UI(lastAutoTarget: String?) -> Bool { + let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed == "/" { return true } + if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines), + !lastAuto.isEmpty, + trimmed == lastAuto + { + return true + } + return false + } +}