Add MuteDeck recording integration (#2)
* feat: add MuteDeck recording integration * fix: make local API connection capture explicit * ci: smoke test MuteDeck API listener --------- Co-authored-by: superdooper86 <[email protected]>
This commit was merged in pull request #2.
This commit is contained in:
@@ -17,5 +17,23 @@ jobs:
|
|||||||
-scheme meetingnotes
|
-scheme meetingnotes
|
||||||
-configuration Debug
|
-configuration Debug
|
||||||
-destination 'platform=macOS'
|
-destination 'platform=macOS'
|
||||||
|
-derivedDataPath "$RUNNER_TEMP/DerivedData"
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
build
|
build
|
||||||
|
- name: Smoke test local API
|
||||||
|
run: |
|
||||||
|
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
|
||||||
|
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
|
||||||
|
"$RUNNER_TEMP/DerivedData/Build/Products/Debug/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
||||||
|
app_pid=$!
|
||||||
|
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if curl -fsS http://127.0.0.1:19880/api/info >"$RUNNER_TEMP/api-info.json"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -q '"name":"MeetingDebrief"' "$RUNNER_TEMP/api-info.json"
|
||||||
|
test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:19880/api/recording/status)" = "401"
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ Implemented:
|
|||||||
- Meeting search functionality
|
- Meeting search functionality
|
||||||
- Abilty to edit system prompt
|
- Abilty to edit system prompt
|
||||||
- Select any compatible Coder model for transcription and note generation
|
- Select any compatible Coder model for transcription and note generation
|
||||||
|
- Automatic start and stop from MuteDeck through a compatible local API
|
||||||
- Auto updates
|
- Auto updates
|
||||||
- Text formatting
|
- Text formatting
|
||||||
- Different note templates
|
- Different note templates
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ struct ContentView: View {
|
|||||||
MeetingListView(settingsViewModel: settingsViewModel)
|
MeetingListView(settingsViewModel: settingsViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
LocalAPIServer.shared.applyConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,32 @@ class KeychainHelper {
|
|||||||
func saveCoderAPIKey(_ apiKey: String) -> Bool {
|
func saveCoderAPIKey(_ apiKey: String) -> Bool {
|
||||||
return save(apiKey, forKey: "coderAPIKey")
|
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
|
/// Saves a string value to the keychain
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@Published var isProcessing = false
|
@Published var isProcessing = false
|
||||||
@Published var activeMeetingId: UUID?
|
@Published var activeMeetingId: UUID?
|
||||||
|
@Published var recordingStartedAt: Date?
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
|
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeMeetingId = meetingId
|
activeMeetingId = meetingId
|
||||||
|
recordingStartedAt = Date()
|
||||||
audioManager.startRecording()
|
audioManager.startRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +89,10 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
|
|
||||||
guard let meetingId = activeMeetingId else {
|
guard let meetingId = activeMeetingId else {
|
||||||
audioManager.cancelRecording()
|
audioManager.cancelRecording()
|
||||||
|
recordingStartedAt = nil
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
recordingStartedAt = nil
|
||||||
let chunks = await audioManager.stopRecordingAndTranscribe()
|
let chunks = await audioManager.stopRecordingAndTranscribe()
|
||||||
activeRecordingTranscriptChunks = chunks
|
activeRecordingTranscriptChunks = chunks
|
||||||
activeRecordingTranscriptChunksUpdated = chunks
|
activeRecordingTranscriptChunksUpdated = chunks
|
||||||
@@ -101,6 +105,7 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
func cancelRecording() {
|
func cancelRecording() {
|
||||||
audioManager.cancelRecording()
|
audioManager.cancelRecording()
|
||||||
activeMeetingId = nil
|
activeMeetingId = nil
|
||||||
|
recordingStartedAt = nil
|
||||||
activeRecordingTranscriptChunks = []
|
activeRecordingTranscriptChunks = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,4 +149,4 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class UserDefaultsManager {
|
|||||||
static let coderBaseURL = "coderBaseURL"
|
static let coderBaseURL = "coderBaseURL"
|
||||||
static let notesModel = "notesModel"
|
static let notesModel = "notesModel"
|
||||||
static let transcriptionModel = "transcriptionModel"
|
static let transcriptionModel = "transcriptionModel"
|
||||||
|
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
|
||||||
|
static let muteDeckAPIPort = "muteDeckAPIPort"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - User Blurb
|
// MARK: - User Blurb
|
||||||
@@ -79,4 +81,17 @@ class UserDefaultsManager {
|
|||||||
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" }
|
get { userDefaults.string(forKey: Keys.transcriptionModel) ?? "groq/whisper-large-v3-turbo" }
|
||||||
set { userDefaults.set(newValue, forKey: Keys.transcriptionModel) }
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ struct Settings: Codable {
|
|||||||
set { UserDefaultsManager.shared.transcriptionModel = newValue }
|
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
|
// System prompt default loading
|
||||||
static func defaultSystemPrompt() -> String {
|
static func defaultSystemPrompt() -> String {
|
||||||
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
||||||
|
|||||||
@@ -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) { [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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PostHog
|
import PostHog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class SettingsViewModel: ObservableObject {
|
class SettingsViewModel: ObservableObject {
|
||||||
@Published var settings = Settings()
|
@Published var settings = Settings()
|
||||||
@Published var saveMessage = ""
|
@Published var saveMessage = ""
|
||||||
@@ -10,8 +11,10 @@ class SettingsViewModel: ObservableObject {
|
|||||||
@Published var coderModels: [CoderModel] = []
|
@Published var coderModels: [CoderModel] = []
|
||||||
@Published var isLoadingModels = false
|
@Published var isLoadingModels = false
|
||||||
@Published var connectionMessage = ""
|
@Published var connectionMessage = ""
|
||||||
|
@Published var muteDeckAPIToken: String
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
muteDeckAPIToken = KeychainHelper.shared.getOrCreateMuteDeckAPIToken()
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
// Only save API key to keychain - other values are automatically saved to UserDefaults
|
// Only save API key to keychain - other values are automatically saved to UserDefaults
|
||||||
// via computed properties when they're modified
|
// via computed properties when they're modified
|
||||||
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
||||||
|
LocalAPIServer.shared.applyConfiguration()
|
||||||
|
|
||||||
if showMessage {
|
if showMessage {
|
||||||
if coderSaved {
|
if coderSaved {
|
||||||
@@ -120,4 +124,12 @@ class SettingsViewModel: ObservableObject {
|
|||||||
// This will cause ContentView to re-evaluate and show onboarding
|
// This will cause ContentView to re-evaluate and show onboarding
|
||||||
NotificationCenter.default.post(name: Notification.Name("OnboardingReset"), object: nil)
|
NotificationCenter.default.post(name: Notification.Name("OnboardingReset"), object: nil)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func applyMuteDeckAPIConfiguration() {
|
||||||
|
LocalAPIServer.shared.applyConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func regenerateMuteDeckAPIToken() {
|
||||||
|
muteDeckAPIToken = KeychainHelper.shared.regenerateMuteDeckAPIToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
|
@StateObject private var localAPIServer = LocalAPIServer.shared
|
||||||
@State private var showingTemplateManager = false
|
@State private var showingTemplateManager = false
|
||||||
|
@State private var confirmingTokenRegeneration = false
|
||||||
@Binding var navigationPath: NavigationPath
|
@Binding var navigationPath: NavigationPath
|
||||||
|
|
||||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||||
@@ -58,6 +60,65 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.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
|
// Note Templates Section: only the Manage Templates button
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -202,6 +263,7 @@ struct SettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.loadTemplates()
|
viewModel.loadTemplates()
|
||||||
viewModel.loadAPIKey()
|
viewModel.loadAPIKey()
|
||||||
|
viewModel.applyMuteDeckAPIConfiguration()
|
||||||
Task { await viewModel.refreshModels() }
|
Task { await viewModel.refreshModels() }
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -214,6 +276,14 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.saveMessage)
|
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 {
|
NavigationStack {
|
||||||
SettingsView(viewModel: SettingsViewModel())
|
SettingsView(viewModel: SettingsViewModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user