feat(macos): serve web chat over localhost to avoid cors
This commit is contained in:
161
apps/macos/Sources/Clawdis/WebChatServer.swift
Normal file
161
apps/macos/Sources/Clawdis/WebChatServer.swift
Normal file
@@ -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[..<qIdx])
|
||||||
|
}
|
||||||
|
if path.hasPrefix("/") { path.removeFirst() }
|
||||||
|
if path.hasPrefix("webchat/") {
|
||||||
|
path = String(path.dropFirst("webchat/".count))
|
||||||
|
}
|
||||||
|
if path.isEmpty { path = "index.html" }
|
||||||
|
|
||||||
|
guard let root else {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let fileURL = root.appendingPathComponent(path)
|
||||||
|
guard fileURL.path.hasPrefix(root.path) else {
|
||||||
|
send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
|
send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let mime = mimeType(forExtension: fileURL.pathExtension)
|
||||||
|
send(status: 200, mime: mime, body: data, over: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(status: Int, mime: String, body: Data, over connection: NWConnection) {
|
||||||
|
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||||
|
"Content-Length: \(body.count)\r\n" +
|
||||||
|
"Content-Type: \(mime)\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
var response = Data(headers.utf8)
|
||||||
|
response.append(body)
|
||||||
|
connection.send(content: response, completion: .contentProcessed { _ in
|
||||||
|
connection.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusText(_ code: Int) -> 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,6 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
config.userContentController = contentController
|
config.userContentController = contentController
|
||||||
config.preferences.isElementFullscreenEnabled = true
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
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
|
// Inject callback receiver stub
|
||||||
let callbackScript = """
|
let callbackScript = """
|
||||||
@@ -89,7 +85,7 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
private func loadPage() {
|
private func loadPage() {
|
||||||
webChatLogger.debug("loadPage begin")
|
webChatLogger.debug("loadPage begin")
|
||||||
guard let webChatURL = Bundle.main.url(forResource: "WebChat", withExtension: nil),
|
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 {
|
else {
|
||||||
NSLog("WebChat resources missing")
|
NSLog("WebChat resources missing")
|
||||||
webChatLogger.error("WebChat resources missing in bundle")
|
webChatLogger.error("WebChat resources missing in bundle")
|
||||||
@@ -108,8 +104,14 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
forMainFrameOnly: true)
|
forMainFrameOnly: true)
|
||||||
self.webView.configuration.userContentController.addUserScript(userScript)
|
self.webView.configuration.userContentController.addUserScript(userScript)
|
||||||
|
|
||||||
self.webView.loadFileURL(htmlURL, allowingReadAccessTo: webChatURL)
|
WebChatServer.shared.start(root: webChatURL)
|
||||||
webChatLogger.debug("loadPage queued HTML into WKWebView fileURL=\(htmlURL.absoluteString, privacy: .public)")
|
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!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
|||||||
Reference in New Issue
Block a user