feat(mac): add agent-controlled Canvas panel
This commit is contained in:
194
apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift
Normal file
194
apps/macos/Sources/Clawdis/CanvasSchemeHandler.swift
Normal file
@@ -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[..<qIdx]) }
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
// Special-case: welcome page when root index is missing.
|
||||
if path.isEmpty {
|
||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
{
|
||||
return self.welcomePage(sessionRoot: sessionRoot)
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
|
||||
guard let fileURL = resolved else {
|
||||
return self.html("Not Found", title: "Canvas: 404")
|
||||
}
|
||||
|
||||
// Directory traversal guard: served files must live under the session root.
|
||||
let standardizedRoot = sessionRoot.standardizedFileURL
|
||||
let standardizedFile = fileURL.standardizedFileURL
|
||||
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: standardizedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(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 "<yolo>/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 "<sessionRoot>/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 = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>\(title)</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background: #fff;
|
||||
color:#111827;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
padding: 18px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.08);
|
||||
}
|
||||
.muted { color:#6b7280; margin-top:8px; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>\(body)</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
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 = """
|
||||
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
|
||||
<div class="muted">Create <code>index.html</code> in:</div>
|
||||
<div style="margin-top:10px;"><code>\(escaped)</code></div>
|
||||
"""
|
||||
return self.html(body, title: "Canvas")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user