Files
meetingnotes/meetingnotes/Services/LocalAPIServer.swift
T

427 lines
15 KiB
Swift

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[..<headerEnd.lowerBound].components(separatedBy: "\r\n")
guard let requestLine = lines.first else { return nil }
let requestParts = requestLine.split(separator: " ", maxSplits: 2).map(String.init)
guard requestParts.count == 3 else { return nil }
let requestTarget = requestParts[1]
let path = URLComponents(string: requestTarget)?.path ?? requestTarget.split(separator: "?", maxSplits: 1).first.map(String.init) ?? requestTarget
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let separator = line.firstIndex(of: ":") else { continue }
let key = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)
headers[key] = value
}
return LocalAPIRequest(method: requestParts[0].uppercased(), path: path, headers: headers)
}
}
@MainActor
private enum LocalAPIRouter {
static func handle(_ request: LocalAPIRequest) -> 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 = ""
}
}
var meetingChanged = false
if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes
meetingChanged = true
}
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: generatedNotes,
templateId: meeting.templateId
) {
meeting.title = title
meetingChanged = true
}
} catch {
// The recording and generated notes remain valid without a generated title.
print("Meeting title generation failed: \(error)")
}
}
if meetingChanged {
_ = 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
}
}
}