Files
clawdbot/apps/macos/Sources/Clawdis/WebChatServer.swift
2025-12-09 00:08:19 +01:00

190 lines
7.6 KiB
Swift

import Foundation
import Network
import OSLog
private let webChatServerLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatServer")
/// Very small loopback-only HTTP server that serves the bundled WebChat assets.
/// Not Sendable-safe; all state lives on the private serial queue.
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) {
self.queue.async {
webChatServerLogger.debug("WebChatServer start requested root=\(root.path, privacy: .public)")
if self.listener != nil { return }
self.root = root
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
params.requiredInterfaceType = .loopback
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 let .failed(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?
self.queue.sync {
if let port {
url = URL(string: "http://127.0.0.1:\(port.rawValue)/webchat/")
}
}
return url
}
private func handle(connection: NWConnection) {
webChatServerLogger.debug("WebChatServer new connection")
connection.stateUpdateHandler = { state in
switch state {
case .ready:
webChatServerLogger.debug("WebChatServer connection ready")
self.receive(on: connection)
case let .failed(error):
webChatServerLogger
.error("WebChatServer connection failed: \(error.localizedDescription, privacy: .public)")
connection.cancel()
default:
break
}
}
connection.start(queue: self.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)
} else if isComplete {
connection.cancel()
return
}
if isComplete || error != nil {
if let error {
webChatServerLogger
.error("WebChatServer receive error: \(error.localizedDescription, privacy: .public)")
}
connection.cancel()
} else {
self.receive(on: connection)
}
}
}
private func respond(to connection: NWConnection, requestData: Data) {
guard let requestText = String(data: requestData, encoding: .utf8) else {
webChatServerLogger.error("WebChatServer could not decode request (\(requestData.count) bytes)")
connection.cancel()
return
}
guard let requestLine = requestText.components(separatedBy: "\r\n").first else {
webChatServerLogger.error("WebChatServer missing request line")
connection.cancel()
return
}
webChatServerLogger.debug("WebChatServer request line=\(requestLine, privacy: .public)")
let parts = requestLine.split(separator: " ")
guard parts.count >= 2, parts[0] == "GET" else {
webChatServerLogger.error("WebChatServer non-GET request: \(requestLine, privacy: .public)")
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/") {
webChatServerLogger.debug("WebChatServer request raw path=\(parts[1], privacy: .public)")
path = String(path.dropFirst("webchat/".count))
}
webChatServerLogger.debug("WebChatServer request path=\(path, privacy: .public)")
if path.isEmpty { path = "index.html" }
guard let root else {
connection.cancel()
return
}
let fileURL = root.appendingPathComponent(path)
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
// Simple directory traversal guard: served files must live under the bundled web root.
guard fileURL.path.hasPrefix(root.path) else {
self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
return
}
guard let data = try? Data(contentsOf: fileURL) else {
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
self.send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection)
return
}
let mime = self.mimeType(forExtension: fileURL.pathExtension)
self.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: "OK"
case 403: "Forbidden"
case 404: "Not Found"
default: "Error"
}
}
private func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
case "html", "htm": "text/html; charset=utf-8"
case "js", "mjs": "application/javascript; charset=utf-8"
case "css": "text/css; charset=utf-8"
case "json", "map": "application/json; charset=utf-8"
case "svg": "image/svg+xml"
case "png": "image/png"
case "jpg", "jpeg": "image/jpeg"
case "gif": "image/gif"
case "woff2": "font/woff2"
case "woff": "font/woff"
case "ttf": "font/ttf"
default: "application/octet-stream"
}
}
}