import Foundation import Network /// A minimal, loopback-only HTTP server that exposes GrammarGem's local engines /// (Harper grammar + the on-device LLM) to a **browser extension** — the /// highest-volume surface for a grammar tool — with NO cloud round-trip. Built on /// Network.framework so it adds no third-party HTTP dependency, keeping the /// privacy/trust surface minimal. /// /// Security posture (deliberate): /// - Binds to 127.0.0.1 only (never a routable interface), via `requiredLocalEndpoint`. /// - Every engine endpoint requires a per-install bearer token (`X-GrammarGem-Token`); /// a web page that doesn't know the token cannot drive the engines. /// - OFF by default — only starts when the user opts in, so the app ships no /// listening socket unless explicitly enabled. /// /// It reuses the SAME engines as the menu-bar app via injected async handlers, so /// there is one engine, one set of prompts (`AIPrompts`), and one entitlement gate /// across the app, the extension, and (later) other backends. final class LocalEngineServer { struct AIRequest { let action: AIAction; let text: String } struct WireSuggestion: Encodable { let location: Int let length: Int let original: String let replacement: String let kind: String let message: String } /// Grammar pass: corrected text + suggestion spans for the extension to underline. typealias GrammarHandler = (String) async -> (corrected: String, suggestions: [WireSuggestion]) /// AI action outcome (a plain enum so a user-facing message needn't be an `Error`). enum AIOutcome { case ok(String); case error(String) } /// AI action: runs the gate + model, returns the result or a user-facing error. typealias AIHandler = (AIRequest) async -> AIOutcome private let port: UInt16 private let token: String private let grammarHandler: GrammarHandler private let aiHandler: AIHandler private var listener: NWListener? private let queue = DispatchQueue(label: "com.grammargem.localserver") init(port: UInt16, token: String, grammarHandler: @escaping GrammarHandler, aiHandler: @escaping AIHandler) { self.port = port self.token = token self.grammarHandler = grammarHandler self.aiHandler = aiHandler } /// A stable per-install token, persisted so the extension can be paired once. static func loadOrCreateToken() -> String { let d = UserDefaults.standard if let existing = d.string(forKey: AppConfig.LocalServer.tokenKey), !existing.isEmpty { return existing } let token = UUID().uuidString + UUID().uuidString.replacingOccurrences(of: "-", with: "") d.set(token, forKey: AppConfig.LocalServer.tokenKey) return token } func start() { guard listener == nil else { return } do { let params = NWParameters.tcp guard let nwPort = NWEndpoint.Port(rawValue: port) else { return } // Loopback-only: refuse to bind to any routable interface. params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: nwPort) let listener = try NWListener(using: params) listener.newConnectionHandler = { [weak self] conn in self?.accept(conn) } listener.start(queue: queue) self.listener = listener Log.app.info("Local engine server on 127.0.0.1:\(self.port, privacy: .public) (extension bridge)") } catch { Log.app.error("Local engine server failed to start: \(error.localizedDescription, privacy: .public)") } } func stop() { listener?.cancel() listener = nil } // MARK: - Connection lifecycle private func accept(_ conn: NWConnection) { conn.start(queue: queue) receive(conn, buffer: Data()) } private func receive(_ conn: NWConnection, buffer: Data) { conn.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { [weak self] data, _, isComplete, error in guard let self else { conn.cancel(); return } var buffer = buffer if let data { buffer.append(data) } if let request = HTTPRequest(buffer), request.isComplete { Task { await self.route(request, on: conn) } return } if isComplete || error != nil { conn.cancel(); return } self.receive(conn, buffer: buffer) // headers/body not fully arrived yet } } // MARK: - Routing private func route(_ request: HTTPRequest, on conn: NWConnection) async { // CORS preflight — extensions issue this before a cross-origin POST. if request.method == "OPTIONS" { send(conn, status: 204, body: Data()) return } if request.path == "/health" || request.path == "/v1/health" { sendJSON(conn, status: 200, HealthResponse(ok: true, app: AppConfig.appName, version: AppConfig.appVersion)) return } // Token gate for every engine endpoint. guard request.headers["x-grammargem-token"] == token else { sendJSON(conn, status: 401, ErrorResponse(error: "unauthorized")) return } switch (request.method, request.path) { case ("POST", "/v1/grammar"): guard let text = decodeText(request.body) else { sendJSON(conn, status: 400, ErrorResponse(error: "missing 'text'")); return } let result = await grammarHandler(text) sendJSON(conn, status: 200, GrammarResponse(corrected: result.corrected, suggestions: result.suggestions)) case ("POST", "/v1/ai"): guard let req = decodeAI(request.body) else { sendJSON(conn, status: 400, ErrorResponse(error: "bad request")); return } switch await aiHandler(req) { case .ok(let out): sendJSON(conn, status: 200, AIResponse(result: out)) case .error(let msg): sendJSON(conn, status: 422, ErrorResponse(error: msg)) } default: sendJSON(conn, status: 404, ErrorResponse(error: "not found")) } } // MARK: - Body decoding private func decodeText(_ body: Data) -> String? { struct P: Decodable { let text: String } guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil } return p.text } private func decodeAI(_ body: Data) -> AIRequest? { struct P: Decodable { let action: String let text: String let instruction: String? let language: String? let mode: String? let tone: String? } guard let p = try? JSONDecoder().decode(P.self, from: body), !p.text.isEmpty else { return nil } let action: AIAction switch p.action { case "rewriteClarity": action = .rewriteClarity case "rewrite": action = .rewrite case "ask": guard let i = p.instruction, !i.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } action = .ask(i) case "translate": guard let l = p.language, !l.isEmpty else { return nil } action = .translate(language: l) case "tone": action = .adjustTone(Tone(rawValue: p.tone ?? "") ?? .professional) case "mode": guard let id = p.mode, let m = ModeRegistry.mode(id: id) else { return nil } action = .applyMode(m) default: return nil } return AIRequest(action: action, text: p.text) } // MARK: - Responses private struct HealthResponse: Encodable { let ok: Bool; let app: String; let version: String } private struct GrammarResponse: Encodable { let corrected: String; let suggestions: [WireSuggestion] } private struct AIResponse: Encodable { let result: String } private struct ErrorResponse: Encodable { let error: String } private func sendJSON(_ conn: NWConnection, status: Int, _ value: T) { let body = (try? JSONEncoder().encode(value)) ?? Data("{}".utf8) send(conn, status: status, body: body) } private func send(_ conn: NWConnection, status: Int, body: Data) { var head = "HTTP/1.1 \(status) \(Self.reason(status))\r\n" head += "Content-Type: application/json\r\n" head += "Content-Length: \(body.count)\r\n" // The extension calls cross-origin; a secret token (not the origin) is the gate. head += "Access-Control-Allow-Origin: *\r\n" head += "Access-Control-Allow-Headers: Content-Type, X-GrammarGem-Token\r\n" head += "Access-Control-Allow-Methods: POST, GET, OPTIONS\r\n" head += "Connection: close\r\n\r\n" var out = Data(head.utf8) out.append(body) conn.send(content: out, completion: .contentProcessed { _ in conn.cancel() }) } private static func reason(_ status: Int) -> String { switch status { case 200: return "OK" case 204: return "No Content" case 400: return "Bad Request" case 401: return "Unauthorized" case 404: return "Not Found" case 422: return "Unprocessable Entity" default: return "Error" } } } /// A just-enough HTTP/1.1 request parser for small JSON POSTs. Returns nil until /// the headers terminator (`\r\n\r\n`) has arrived; `isComplete` is false until the /// full `Content-Length` body is buffered, so the receive loop knows to read more. private struct HTTPRequest { let method: String let path: String let headers: [String: String] let body: Data let isComplete: Bool init?(_ raw: Data) { guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil } guard let headerString = String(data: raw[..= 2 else { return nil } method = String(parts[0]) path = String(parts[1]) lines.removeFirst() var hdrs: [String: String] = [:] for line in lines { guard let colon = line.firstIndex(of: ":") else { continue } let key = line[..= contentLength } }