import Foundation import Network private struct LocalAPIRequest { let method: String let path: String let headers: [String: String] } private struct LocalAPIResponse { let status: Int let reason: String let payload: [String: Any] static func json(status: Int = 200, reason: String = "OK", _ payload: [String: Any]) -> LocalAPIResponse { LocalAPIResponse(status: status, reason: reason, payload: payload) } func encoded() -> Data { let body = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8) let headers = [ "HTTP/1.1 \(status) \(reason)", "Content-Type: application/json; charset=utf-8", "Content-Length: \(body.count)", "Connection: close", "Cache-Control: no-store", "", "" ].joined(separator: "\r\n") var response = Data(headers.utf8) response.append(body) return response } } private final class LocalAPIConnection { private let connection: NWConnection private let queue: DispatchQueue private var buffer = Data() init(connection: NWConnection, queue: DispatchQueue) { self.connection = connection self.queue = queue } func start() { connection.start(queue: queue) receive() } private func receive() { connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [self] data, _, isComplete, error in if let data { self.buffer.append(data) } if self.buffer.range(of: Data("\r\n\r\n".utf8)) != nil { self.handleRequest() return } if self.buffer.count > 64 * 1024 { self.send(.json(status: 413, reason: "Payload Too Large", ["error": "request headers are too large"])) return } if isComplete || error != nil { self.connection.cancel() return } self.receive() } } private func handleRequest() { guard let request = Self.parse(buffer) else { send(.json(status: 400, reason: "Bad Request", ["error": "invalid HTTP request"])) return } Task { @MainActor in let response = LocalAPIRouter.handle(request) send(response) } } private func send(_ response: LocalAPIResponse) { connection.send(content: response.encoded(), completion: .contentProcessed { [connection] _ in connection.cancel() }) } private static func parse(_ data: Data) -> LocalAPIRequest? { guard let text = String(data: data, encoding: .utf8), let headerEnd = text.range(of: "\r\n\r\n") else { return nil } let lines = text[.. LocalAPIResponse { let path = request.path.count > 1 ? request.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) : request.path let normalizedPath = path.hasPrefix("/") ? path : "/" + path if request.method == "GET", normalizedPath == "/api/info" { return .json(infoPayload()) } guard authorized(request) else { return .json(status: 401, reason: "Unauthorized", ["error": "invalid or missing bearer token"]) } let controller = LocalRecordingController.shared switch (request.method, normalizedPath) { case ("GET", "/api/recording/status"): return .json(controller.statusPayload()) case ("POST", "/api/recording/start"): do { return .json(try controller.startRecording()) } catch { return .json(status: 409, reason: "Conflict", [ "error": error.localizedDescription, "state": "processing" ]) } case ("POST", "/api/recording/stop"): return .json(controller.stopRecording()) case ("POST", "/api/recording/cancel"): return .json(controller.cancelRecording()) default: return .json(status: 404, reason: "Not Found", ["error": "endpoint not found"]) } } private static func authorized(_ request: LocalAPIRequest) -> Bool { guard let authorization = request.headers["authorization"], authorization.lowercased().hasPrefix("bearer ") else { return false } let supplied = String(authorization.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) return timingSafeEqual(supplied, KeychainHelper.shared.getOrCreateMuteDeckAPIToken()) } private static func timingSafeEqual(_ lhs: String, _ rhs: String) -> Bool { let left = Array(lhs.utf8) let right = Array(rhs.utf8) guard left.count == right.count else { return false } var difference: UInt8 = 0 for index in left.indices { difference |= left[index] ^ right[index] } return difference == 0 } private static func infoPayload() -> [String: Any] { // MuteDeck identifies compatible local API targets by the MeetingDebrief contract. [ "status": "ok", "name": "MeetingDebrief", "implementation": "Meetingnotes", "api_version": "1", "apiVersion": "1", "version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0", "endpoints": [ "GET /api/info", "GET /api/recording/status", "POST /api/recording/start", "POST /api/recording/stop", "POST /api/recording/cancel" ] ] } } private enum LocalRecordingError: LocalizedError { case processing case saveFailed var errorDescription: String? { switch self { case .processing: return "The previous meeting is still processing." case .saveFailed: return "Could not create a meeting for this recording." } } } @MainActor private final class LocalRecordingController { static let shared = LocalRecordingController() private let recordingManager = RecordingSessionManager.shared private var isStopping = false private init() {} func statusPayload() -> [String: Any] { let state: String if isStopping || recordingManager.isProcessing { state = "processing" } else if recordingManager.isRecording { state = "recording" } else if recordingManager.activeMeetingId != nil { state = "starting" } else { state = "idle" } let isRecording = state == "recording" || state == "starting" let elapsed = recordingManager.recordingStartedAt.map { max(0, Int(Date().timeIntervalSince($0))) } ?? 0 let sessionID: Any = recordingManager.activeMeetingId.map { $0.uuidString as Any } ?? NSNull() return [ "success": true, "status": state, "state": state, "recording": isRecording, "is_recording": isRecording, "isRecording": isRecording, "paused": false, "is_paused": false, "isPaused": false, "duration": elapsed, "duration_seconds": elapsed, "durationSeconds": elapsed, "session_id": sessionID, "sessionId": sessionID ] } func startRecording() throws -> [String: Any] { if isStopping || recordingManager.isProcessing { throw LocalRecordingError.processing } if recordingManager.activeMeetingId != nil { return statusPayload() } let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID()) guard LocalStorageManager.shared.saveMeeting(meeting) else { throw LocalRecordingError.saveFailed } NotificationCenter.default.post(name: .meetingSaved, object: meeting) recordingManager.startRecording(for: meeting.id) return statusPayload() } func stopRecording() -> [String: Any] { guard !isStopping, recordingManager.activeMeetingId != nil else { return statusPayload() } isStopping = true Task { [weak self] in await self?.finishRecording() } return statusPayload() } func cancelRecording() -> [String: Any] { guard !isStopping else { return statusPayload() } let meetingID = recordingManager.activeMeetingId recordingManager.cancelRecording() if let meetingID, let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) { _ = LocalStorageManager.shared.deleteMeeting(meeting) NotificationCenter.default.post(name: .meetingDeleted, object: meeting) } return statusPayload() } private func finishRecording() async { let meetingID = recordingManager.activeMeetingId let chunks = await recordingManager.stopRecording() guard let meetingID, var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else { isStopping = false return } meeting.transcriptChunks = chunks let templates = LocalStorageManager.shared.loadTemplates() if meeting.templateId == nil { meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) } _ = LocalStorageManager.shared.saveMeeting(meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting) if !meeting.formattedTranscript.isEmpty { let stream = NotesGenerator.shared.generateNotesStream( meeting: meeting, userBlurb: UserDefaultsManager.shared.userBlurb, systemPrompt: UserDefaultsManager.shared.systemPrompt, templateId: meeting.templateId ) var generatedNotes = "" for await result in stream { switch result { case .content(let content): generatedNotes += content case .error: generatedNotes = "" } } if !generatedNotes.isEmpty { meeting.generatedNotes = generatedNotes _ = LocalStorageManager.shared.saveMeeting(meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting) } } isStopping = false } } @MainActor final class LocalAPIServer: ObservableObject { static let shared = LocalAPIServer() @Published private(set) var isRunning = false @Published private(set) var errorMessage: String? @Published private(set) var listeningPort: UInt16? private let queue = DispatchQueue(label: "io.meetingnotes.local-api", qos: .userInitiated) private var listener: NWListener? private init() {} var statusText: String { if isRunning, let listeningPort { return "Listening on 127.0.0.1:\(listeningPort)" } return errorMessage ?? "Stopped" } func applyConfiguration() { guard UserDefaultsManager.shared.muteDeckAPIEnabled else { stop() return } let configuredPort = UserDefaultsManager.shared.muteDeckAPIPort guard let port = UInt16(exactly: configuredPort), port > 0 else { stop() errorMessage = "Enter a port between 1 and 65535." return } if isRunning, listeningPort == port { return } start(port: port) } private func start(port: UInt16) { stop() do { let parameters = NWParameters.tcp parameters.requiredLocalEndpoint = .hostPort( host: NWEndpoint.Host("127.0.0.1"), port: NWEndpoint.Port(rawValue: port)! ) let listener = try NWListener(using: parameters) let queue = self.queue listener.newConnectionHandler = { connection in LocalAPIConnection(connection: connection, queue: queue).start() } listener.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in guard let self else { return } switch state { case .ready: isRunning = true listeningPort = port errorMessage = nil case .failed(let error): isRunning = false listeningPort = nil errorMessage = "Local API failed: \(error.localizedDescription)" case .cancelled: isRunning = false listeningPort = nil default: break } } } self.listener = listener _ = KeychainHelper.shared.getOrCreateMuteDeckAPIToken() listener.start(queue: queue) } catch { errorMessage = "Local API failed: \(error.localizedDescription)" } } private func stop() { listener?.cancel() listener = nil isRunning = false listeningPort = nil if !UserDefaultsManager.shared.muteDeckAPIEnabled { errorMessage = nil } } }