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 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[.. \(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", 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 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 } } }