diff --git a/apps/macos/Sources/Clawdis/WebChatServer.swift b/apps/macos/Sources/Clawdis/WebChatServer.swift new file mode 100644 index 000000000..f317b0697 --- /dev/null +++ b/apps/macos/Sources/Clawdis/WebChatServer.swift @@ -0,0 +1,161 @@ +import Foundation +import Network +import OSLog + +private let webChatServerLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatServer") + +final class WebChatServer: @unchecked Sendable { + static let shared = WebChatServer() + + private let queue = DispatchQueue(label: "com.steipete.clawdis.webchatserver") + private var listener: NWListener? + private var root: URL? + private var port: NWEndpoint.Port? + + /// Start the local HTTP server if it isn't already running. Safe to call multiple times. + func start(root: URL) { + queue.async { + if self.listener != nil { return } + self.root = root + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + do { + let listener = try NWListener(using: params, on: .any) + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.port = listener.port + webChatServerLogger.debug("WebChatServer ready on 127.0.0.1:\(listener.port?.rawValue ?? 0)") + case .failed(let error): + webChatServerLogger.error("WebChatServer failed: \(error.localizedDescription, privacy: .public)") + self?.listener = nil + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection) + } + listener.start(queue: self.queue) + self.listener = listener + } catch { + webChatServerLogger.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Returns the base URL once the server is ready, otherwise nil. + func baseURL() -> URL? { + var url: URL? + queue.sync { + if let port { + url = URL(string: "http://127.0.0.1:\(port.rawValue)/webchat/") + } + } + return url + } + + private func handle(connection: NWConnection) { + connection.stateUpdateHandler = { state in + switch state { + case .ready: + self.receive(on: connection) + case .failed(let error): + webChatServerLogger.error("WebChatServer connection failed: \(error.localizedDescription, privacy: .public)") + connection.cancel() + default: + break + } + } + connection.start(queue: queue) + } + + private func receive(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let data, !data.isEmpty { + self.respond(to: connection, requestData: data) + } + if isComplete || error != nil { + connection.cancel() + } else { + self.receive(on: connection) + } + } + } + + private func respond(to connection: NWConnection, requestData: Data) { + guard let requestLine = String(data: requestData, encoding: .utf8)?.components(separatedBy: "\r\n").first else { + connection.cancel() + return + } + let parts = requestLine.split(separator: " ") + guard parts.count >= 2, parts[0] == "GET" else { + connection.cancel() + return + } + var path = String(parts[1]) + if let qIdx = path.firstIndex(of: "?") { + path = String(path[.. String { + switch code { + case 200: return "OK" + case 403: return "Forbidden" + case 404: return "Not Found" + default: return "Error" + } + } + + private func mimeType(forExtension ext: String) -> String { + switch ext.lowercased() { + case "html", "htm": return "text/html; charset=utf-8" + case "js", "mjs": return "application/javascript; charset=utf-8" + case "css": return "text/css; charset=utf-8" + case "json": return "application/json; charset=utf-8" + case "map": return "application/json; charset=utf-8" + case "svg": return "image/svg+xml" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "woff2": return "font/woff2" + case "woff": return "font/woff" + case "ttf": return "font/ttf" + default: return "application/octet-stream" + } + } +} diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index e25d946a1..fbfd67831 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -20,10 +20,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, config.userContentController = contentController config.preferences.isElementFullscreenEnabled = true config.preferences.setValue(true, forKey: "developerExtrasEnabled") - // Allow module imports between local file:// resources (needed because WebKit treats distinct - // file URLs as cross-origin by default). - config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") - config.preferences.setValue(true, forKey: "allowUniversalAccessFromFileURLs") // Inject callback receiver stub let callbackScript = """ @@ -89,7 +85,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, private func loadPage() { webChatLogger.debug("loadPage begin") guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil), - let htmlURL = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "WebChat") + let htmlURL = URL(string: "index.html") else { NSLog("WebChat resources missing") webChatLogger.error("WebChat resources missing in bundle") @@ -108,8 +104,14 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, forMainFrameOnly: true) self.webView.configuration.userContentController.addUserScript(userScript) - self.webView.loadFileURL(htmlURL, allowingReadAccessTo: webChatURL) - webChatLogger.debug("loadPage queued HTML into WKWebView fileURL=\(htmlURL.absoluteString, privacy: .public)") + WebChatServer.shared.start(root: webChatURL) + guard let baseURL = WebChatServer.shared.baseURL() else { + webChatLogger.error("WebChatServer not ready; cannot load web chat") + return + } + let url = baseURL.appendingPathComponent(htmlURL.relativePath) + self.webView.load(URLRequest(url: url)) + webChatLogger.debug("loadPage queued HTML into WKWebView url=\(url.absoluteString, privacy: .public)") } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {