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) { self.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 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) { 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) } 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 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: "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": "application/json; charset=utf-8" case "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" } } }