import ClawdbotKit import Foundation import OSLog import WebKit private let canvasLogger = Logger(subsystem: "com.clawdbot", 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 encoding = self.textEncodingName(forMimeType: mime) let urlResponse = URLResponse( url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: encoding) 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[.. \(servedPath, privacy: .public)") return CanvasResponse(mime: mime, data: data) } catch { let failedPath = standardizedFile.path let errorText = error.localizedDescription canvasLogger .error( "failed reading \(failedPath, privacy: .public): \(errorText, 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", 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") } private func scaffoldPage(sessionRoot: URL) -> CanvasResponse { // Default Canvas UX: when no index exists, show the built-in scaffold page. if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") { return CanvasResponse(mime: "text/html", data: data) } // Fallback for dev misconfiguration: show the classic welcome page. return self.welcomePage(sessionRoot: sessionRoot) } private func loadBundledResourceData(relativePath: String) -> Data? { let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if trimmed.contains("..") || trimmed.contains("\\") { return nil } let parts = trimmed.split(separator: "/") guard let filename = parts.last else { return nil } let subdirectory = parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil let fileURL = URL(fileURLWithPath: String(filename)) let ext = fileURL.pathExtension let name = fileURL.deletingPathExtension().lastPathComponent guard !name.isEmpty, !ext.isEmpty else { return nil } let bundle = ClawdbotKitResources.bundle let resourceURL = bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory) ?? bundle.url(forResource: name, withExtension: ext) guard let resourceURL else { return nil } return try? Data(contentsOf: resourceURL) } private func textEncodingName(forMimeType mimeType: String) -> String? { if mimeType.hasPrefix("text/") { return "utf-8" } switch mimeType { case "application/javascript", "application/json", "image/svg+xml": return "utf-8" default: return nil } } } #if DEBUG extension CanvasSchemeHandler { func _testResponse(for url: URL) -> (mime: String, data: Data) { let response = self.response(for: url) return (response.mime, response.data) } func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath) } func _testTextEncodingName(for mimeType: String) -> String? { self.textEncodingName(forMimeType: mimeType) } } #endif