From 0c2d1ff1feeb24b8f996d3d0c1d344e6b98e1b60 Mon Sep 17 00:00:00 2001 From: superdooper86 Date: Tue, 14 Jul 2026 22:37:00 +0200 Subject: [PATCH] feat: add MuteDeck recording integration --- README.md | 1 + meetingnotes/ContentView.swift | 3 + meetingnotes/Managers/KeychainHelper.swift | 26 ++ .../Managers/RecordingSessionManager.swift | 7 +- .../Managers/UserDefaultsManager.swift | 15 + meetingnotes/Models/Settings.swift | 10 + meetingnotes/Services/LocalAPIServer.swift | 409 ++++++++++++++++++ .../ViewModels/SettingsViewModel.swift | 14 +- meetingnotes/Views/SettingsView.swift | 72 ++- 9 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 meetingnotes/Services/LocalAPIServer.swift diff --git a/README.md b/README.md index 0b59222..0d3fa80 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Implemented: - Meeting search functionality - Abilty to edit system prompt - Select any compatible Coder model for transcription and note generation +- Automatic start and stop from MuteDeck through a compatible local API - Auto updates - Text formatting - Different note templates diff --git a/meetingnotes/ContentView.swift b/meetingnotes/ContentView.swift index 90adfbd..9b14881 100644 --- a/meetingnotes/ContentView.swift +++ b/meetingnotes/ContentView.swift @@ -19,6 +19,9 @@ struct ContentView: View { MeetingListView(settingsViewModel: settingsViewModel) } } + .task { + LocalAPIServer.shared.applyConfiguration() + } } } diff --git a/meetingnotes/Managers/KeychainHelper.swift b/meetingnotes/Managers/KeychainHelper.swift index c6579f0..3ba052e 100644 --- a/meetingnotes/Managers/KeychainHelper.swift +++ b/meetingnotes/Managers/KeychainHelper.swift @@ -24,6 +24,32 @@ class KeychainHelper { func saveCoderAPIKey(_ apiKey: String) -> Bool { return save(apiKey, forKey: "coderAPIKey") } + + func getOrCreateMuteDeckAPIToken() -> String { + if let token = get(forKey: "muteDeckAPIToken"), !token.isEmpty { + return token + } + return regenerateMuteDeckAPIToken() + } + + func regenerateMuteDeckAPIToken() -> String { + var bytes = [UInt8](repeating: 0, count: 24) + let status = bytes.withUnsafeMutableBytes { buffer in + SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) + } + let randomPart: String + if status == errSecSuccess { + randomPart = Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } else { + randomPart = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + let token = "trby_\(randomPart)" + _ = save(token, forKey: "muteDeckAPIToken") + return token + } /// Saves a string value to the keychain /// - Parameters: diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index b248d27..2269a8e 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -10,6 +10,7 @@ class RecordingSessionManager: ObservableObject { @Published var isRecording = false @Published var isProcessing = false @Published var activeMeetingId: UUID? + @Published var recordingStartedAt: Date? @Published var errorMessage: String? @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] @@ -79,6 +80,7 @@ class RecordingSessionManager: ObservableObject { } activeMeetingId = meetingId + recordingStartedAt = Date() audioManager.startRecording() } @@ -87,8 +89,10 @@ class RecordingSessionManager: ObservableObject { guard let meetingId = activeMeetingId else { audioManager.cancelRecording() + recordingStartedAt = nil return [] } + recordingStartedAt = nil let chunks = await audioManager.stopRecordingAndTranscribe() activeRecordingTranscriptChunks = chunks activeRecordingTranscriptChunksUpdated = chunks @@ -101,6 +105,7 @@ class RecordingSessionManager: ObservableObject { func cancelRecording() { audioManager.cancelRecording() activeMeetingId = nil + recordingStartedAt = nil activeRecordingTranscriptChunks = [] } @@ -144,4 +149,4 @@ class RecordingSessionManager: ObservableObject { return [] } } -} \ No newline at end of file +} diff --git a/meetingnotes/Managers/UserDefaultsManager.swift b/meetingnotes/Managers/UserDefaultsManager.swift index 54f6708..aeaadf0 100644 --- a/meetingnotes/Managers/UserDefaultsManager.swift +++ b/meetingnotes/Managers/UserDefaultsManager.swift @@ -21,6 +21,8 @@ class UserDefaultsManager { static let coderBaseURL = "coderBaseURL" static let notesModel = "notesModel" static let transcriptionModel = "transcriptionModel" + static let muteDeckAPIEnabled = "muteDeckAPIEnabled" + static let muteDeckAPIPort = "muteDeckAPIPort" } // MARK: - User Blurb @@ -79,4 +81,17 @@ class UserDefaultsManager { get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" } set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) } } + + var muteDeckAPIEnabled: Bool { + get { userDefaults.bool(forKey: Keys.muteDeckAPIEnabled) } + set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIEnabled) } + } + + var muteDeckAPIPort: Int { + get { + let stored = userDefaults.integer(forKey: Keys.muteDeckAPIPort) + return stored == 0 ? 9880 : stored + } + set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) } + } } diff --git a/meetingnotes/Models/Settings.swift b/meetingnotes/Models/Settings.swift index 5ddcc81..07054d0 100644 --- a/meetingnotes/Models/Settings.swift +++ b/meetingnotes/Models/Settings.swift @@ -44,6 +44,16 @@ struct Settings: Codable { set { UserDefaultsManager.shared.transcriptionModel = newValue } } + var muteDeckAPIEnabled: Bool { + get { UserDefaultsManager.shared.muteDeckAPIEnabled } + set { UserDefaultsManager.shared.muteDeckAPIEnabled = newValue } + } + + var muteDeckAPIPort: Int { + get { UserDefaultsManager.shared.muteDeckAPIPort } + set { UserDefaultsManager.shared.muteDeckAPIPort = newValue } + } + // System prompt default loading static func defaultSystemPrompt() -> String { guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"), diff --git a/meetingnotes/Services/LocalAPIServer.swift b/meetingnotes/Services/LocalAPIServer.swift new file mode 100644 index 0000000..9b2eb33 --- /dev/null +++ b/meetingnotes/Services/LocalAPIServer.swift @@ -0,0 +1,409 @@ +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) { data, _, isComplete, error in + if let data { buffer.append(data) } + + if buffer.range(of: Data("\r\n\r\n".utf8)) != nil { + handleRequest() + return + } + if buffer.count > 64 * 1024 { + send(.json(status: 413, reason: "Payload Too Large", ["error": "request headers are too large"])) + return + } + if isComplete || error != nil { + connection.cancel() + return + } + 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() + 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 = UserDefaultsManager.shared.selectedTemplateId + ?? templates.first(where: { $0.title == "Standard Meeting" })?.id + ?? templates.first?.id + } + _ = 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 + } + } +} diff --git a/meetingnotes/ViewModels/SettingsViewModel.swift b/meetingnotes/ViewModels/SettingsViewModel.swift index d310fa1..4506ca7 100644 --- a/meetingnotes/ViewModels/SettingsViewModel.swift +++ b/meetingnotes/ViewModels/SettingsViewModel.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import PostHog +@MainActor class SettingsViewModel: ObservableObject { @Published var settings = Settings() @Published var saveMessage = "" @@ -10,8 +11,10 @@ class SettingsViewModel: ObservableObject { @Published var coderModels: [CoderModel] = [] @Published var isLoadingModels = false @Published var connectionMessage = "" + @Published var muteDeckAPIToken: String init() { + muteDeckAPIToken = KeychainHelper.shared.getOrCreateMuteDeckAPIToken() loadTemplates() } @@ -84,6 +87,7 @@ class SettingsViewModel: ObservableObject { // Only save API key to keychain - other values are automatically saved to UserDefaults // via computed properties when they're modified let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey) + LocalAPIServer.shared.applyConfiguration() if showMessage { if coderSaved { @@ -120,4 +124,12 @@ class SettingsViewModel: ObservableObject { // This will cause ContentView to re-evaluate and show onboarding NotificationCenter.default.post(name: Notification.Name("OnboardingReset"), object: nil) } -} \ No newline at end of file + + func applyMuteDeckAPIConfiguration() { + LocalAPIServer.shared.applyConfiguration() + } + + func regenerateMuteDeckAPIToken() { + muteDeckAPIToken = KeychainHelper.shared.regenerateMuteDeckAPIToken() + } +} diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift index c174456..25a9cc8 100644 --- a/meetingnotes/Views/SettingsView.swift +++ b/meetingnotes/Views/SettingsView.swift @@ -2,7 +2,9 @@ import SwiftUI struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel + @StateObject private var localAPIServer = LocalAPIServer.shared @State private var showingTemplateManager = false + @State private var confirmingTokenRegeneration = false @Binding var navigationPath: NavigationPath init(viewModel: SettingsViewModel, navigationPath: Binding = .constant(NavigationPath())) { @@ -58,6 +60,65 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) } + + VStack(alignment: .leading, spacing: 12) { + Toggle("Enable MuteDeck Integration", isOn: $viewModel.settings.muteDeckAPIEnabled) + .font(.headline) + .onChange(of: viewModel.settings.muteDeckAPIEnabled) { _, _ in + viewModel.applyMuteDeckAPIConfiguration() + } + + if viewModel.settings.muteDeckAPIEnabled { + LabeledContent("Host") { + Text("127.0.0.1") + .textSelection(.enabled) + } + + LabeledContent("Port") { + TextField("Port", value: $viewModel.settings.muteDeckAPIPort, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 110) + .onSubmit { + viewModel.applyMuteDeckAPIConfiguration() + } + } + + LabeledContent("API Token") { + HStack(spacing: 8) { + Text(viewModel.muteDeckAPIToken) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .textSelection(.enabled) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(viewModel.muteDeckAPIToken, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy API token") + + Button { + confirmingTokenRegeneration = true + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help("Regenerate API token") + } + } + + HStack(spacing: 8) { + Circle() + .fill(localAPIServer.isRunning ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(localAPIServer.statusText) + .font(.caption) + .foregroundColor(.secondary) + } + } + } // Note Templates Section: only the Manage Templates button VStack(alignment: .leading, spacing: 8) { @@ -202,6 +263,7 @@ struct SettingsView: View { .onAppear { viewModel.loadTemplates() viewModel.loadAPIKey() + viewModel.applyMuteDeckAPIConfiguration() Task { await viewModel.refreshModels() } } .onDisappear { @@ -214,6 +276,14 @@ struct SettingsView: View { } message: { Text(viewModel.saveMessage) } + .confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) { + Button("Regenerate", role: .destructive) { + viewModel.regenerateMuteDeckAPIToken() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("MuteDeck will need the new token before it can control recordings again.") + } } } @@ -221,4 +291,4 @@ struct SettingsView: View { NavigationStack { SettingsView(viewModel: SettingsViewModel()) } -} \ No newline at end of file +}