From f5487348fbea8a19b282d7d1949303059b9751dc Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 25 Sep 2026 09:45:24 +0800 Subject: [PATCH] fix: keep recording active when windows close or minimize (cherry picked from commit fd3cc3d2884b5ef75a9a44b8563910cb3cf376d1) --- .codex/environments/environment.toml | 11 + Seminarly.xcodeproj/project.pbxproj | 8 + Sources/Audio/AudioCaptureManager.swift | 6 +- Sources/Audio/RecordingSession.swift | 349 +++++++++++++ Sources/SeminarlyApp.swift | 52 +- .../Transcription/TranscriptionEngine.swift | 2 +- Sources/Views/ContentView.swift | 46 +- Sources/Views/MenuBarView.swift | 19 +- Sources/Views/RecordingView.swift | 467 +++--------------- Tests/RecordingSessionTests.swift | 246 +++++++++ docs/ARCHITECTURE.md | 7 + docs/TESTING.md | 8 + script/build_and_run.sh | 57 +++ 13 files changed, 816 insertions(+), 462 deletions(-) create mode 100644 .codex/environments/environment.toml create mode 100644 Sources/Audio/RecordingSession.swift create mode 100644 Tests/RecordingSessionTests.swift create mode 100755 script/build_and_run.sh diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..bd4d436 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "Seminarly" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "./script/build_and_run.sh" diff --git a/Seminarly.xcodeproj/project.pbxproj b/Seminarly.xcodeproj/project.pbxproj index f42fade..db1bfea 100644 --- a/Seminarly.xcodeproj/project.pbxproj +++ b/Seminarly.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 4DC885FFE1839F0FC7268D6E /* AnthropicStreamAccumulatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 903EC155361F8700E8718CE6 /* AnthropicStreamAccumulatorTests.swift */; }; 4E747E200CB180C9D52D7681 /* LLMSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7B44FEA6BC39BCDB826FD57 /* LLMSettings.swift */; }; 4F5E3E3582239412862DCC49 /* CodexProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88213CDD8C472F95C2EF62E4 /* CodexProtocol.swift */; }; + 4FC99892F85190C8A43916F8 /* RecordingSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6313A01FBE8F4493C6E9B6 /* RecordingSessionTests.swift */; }; 55625974F4942AE55F1318AB /* ReleaseArchitecture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71025614BFFFA23D8CF5F4AF /* ReleaseArchitecture.swift */; }; 569391AC2571A5DDBA8BEEFF /* NoteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10363E60B17A3874C19B4B56 /* NoteTemplate.swift */; }; 588808A26C68718D3CF6A823 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 582B18F272268549F5F54328 /* Assets.xcassets */; }; @@ -106,6 +107,7 @@ EAACDE7F9E587D612B126BEF /* LLMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 442C1A5FDC026A3F72D52856 /* LLMProvider.swift */; }; EB318B50A689FE9778820349 /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588D1CC22EFB86BAF04A5AA7 /* UpdateChecker.swift */; }; EBF6C473E13F95D251C6261C /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 1C52021159AD95599C2F300B /* FluidAudio */; }; + EC7813615E21C22D6E9A0945 /* RecordingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FF02FADB4E83B9F73313F71 /* RecordingSession.swift */; }; ECCB4A8391BF8A961103FE9F /* DatabaseErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F27B4B04E1F0F10D8FFA75 /* DatabaseErrorView.swift */; }; F0AFACE2FE245FEDA50518DE /* UpdateBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D60962B7A4D96637E48113D /* UpdateBannerView.swift */; }; F1B6E3F395BD6AD1A30E5CFC /* MeetingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BD96750494903A36134D6E /* MeetingModel.swift */; }; @@ -226,9 +228,11 @@ 88213CDD8C472F95C2EF62E4 /* CodexProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProtocol.swift; sourceTree = ""; }; 88870F818336A1C4677C1657 /* CoreAudioUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAudioUtils.swift; sourceTree = ""; }; 89274FD20CD968F01BFB40A8 /* CodexAppServerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexAppServerClient.swift; sourceTree = ""; }; + 8B6313A01FBE8F4493C6E9B6 /* RecordingSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSessionTests.swift; sourceTree = ""; }; 8C186469B8F8014AFC557D7D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 8D26185876B64E5A4D977EC7 /* GetCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCommand.swift; sourceTree = ""; }; 8E74373F4A4A05A9D090A01D /* MicrophoneCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophoneCaptureManager.swift; sourceTree = ""; }; + 8FF02FADB4E83B9F73313F71 /* RecordingSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingSession.swift; sourceTree = ""; }; 903EC155361F8700E8718CE6 /* AnthropicStreamAccumulatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicStreamAccumulatorTests.swift; sourceTree = ""; }; 91A38B032D5DCEDF17A9AD90 /* LanguageAwareDiarizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageAwareDiarizationTests.swift; sourceTree = ""; }; 9426EFC8C62BB0056C557BB1 /* DatabaseRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRecoveryTests.swift; sourceTree = ""; }; @@ -344,6 +348,7 @@ 88870F818336A1C4677C1657 /* CoreAudioUtils.swift */, 8E74373F4A4A05A9D090A01D /* MicrophoneCaptureManager.swift */, 2469C952DE0832A0125C40D2 /* ProcessTapManager.swift */, + 8FF02FADB4E83B9F73313F71 /* RecordingSession.swift */, ); path = Audio; sourceTree = ""; @@ -492,6 +497,7 @@ 24880F6B9E28562AA8EBF0CC /* NoteTemplateTests.swift */, 2EAE57A6478CFD856050E18F /* PromptTemplatesLanguageTests.swift */, 1D3D73C713AF6C48A7965274 /* PromptTemplatesTests.swift */, + 8B6313A01FBE8F4493C6E9B6 /* RecordingSessionTests.swift */, 3E2D64DCE0914E23A49899C4 /* SemanticVersionTests.swift */, 9F4558CAE01CE2FCCF0E53AB /* SeminarlyCLIInstallerTests.swift */, DC1BF2B7B1FE715610F15DF2 /* SessionFormatterTests.swift */, @@ -745,6 +751,7 @@ 5CAB6C3A7C986808A29C93EA /* NoteTemplateTests.swift in Sources */, 9CB8E06AF6CF12FA3615D890 /* PromptTemplatesLanguageTests.swift in Sources */, DCB56C4444FEB5152634B745 /* PromptTemplatesTests.swift in Sources */, + 4FC99892F85190C8A43916F8 /* RecordingSessionTests.swift in Sources */, 264DF0EEE30C845F5C539886 /* SemanticVersionTests.swift in Sources */, 09B30105E76725B4352F1385 /* SeminarlyCLIInstallerTests.swift in Sources */, 3086DF2F96EE20D942903C43 /* SessionFormatterTests.swift in Sources */, @@ -825,6 +832,7 @@ 7508DA39BFFE7BC8286E059E /* OpenAICompatibleProvider.swift in Sources */, 26E009AD005BE831D60E27B9 /* ProcessTapManager.swift in Sources */, 2380BC6BA20BF1AFA7B9EA35 /* PromptTemplates.swift in Sources */, + EC7813615E21C22D6E9A0945 /* RecordingSession.swift in Sources */, CBD75ED7F232B718B51816A1 /* RecordingView.swift in Sources */, 55625974F4942AE55F1318AB /* ReleaseArchitecture.swift in Sources */, 430D6B958C0A36A57821B258 /* SemanticVersion.swift in Sources */, diff --git a/Sources/Audio/AudioCaptureManager.swift b/Sources/Audio/AudioCaptureManager.swift index b86334a..442ab54 100644 --- a/Sources/Audio/AudioCaptureManager.swift +++ b/Sources/Audio/AudioCaptureManager.swift @@ -182,7 +182,11 @@ final class AudioBufferAccumulator: @unchecked Sendable { @MainActor final class AudioCaptureManager: ObservableObject { - @Published var state: CaptureState = .idle + @Published var state: CaptureState = .idle { + didSet { onStateChange?(state) } + } + /// Session lifecycle handling must run even when no SwiftUI view is mounted. + var onStateChange: ((CaptureState) -> Void)? @Published var availableProcesses: [AudioProcess] = [] @Published var selectedProcess: AudioProcess? @Published var captureMicrophone = true diff --git a/Sources/Audio/RecordingSession.swift b/Sources/Audio/RecordingSession.swift new file mode 100644 index 0000000..5245a95 --- /dev/null +++ b/Sources/Audio/RecordingSession.swift @@ -0,0 +1,349 @@ +import Foundation +import Combine +import Observation +import SwiftData +import os + +private let logger = Logger(subsystem: "ai.seminarly.Seminarly", category: "RecordingSession") + +/// Owned by AppState, so capture, transcription, notes and saving survive the +/// destruction of every window. Views only observe and control this session. +@MainActor +@Observable +final class RecordingSession { + let captureManager: AudioCaptureManager + let transcriptionEngine: TranscriptionEngine + let diarizationEngine: NeuralDiarizationEngine + + private(set) var id = UUID() + private(set) var ownsRecordingSession = false + private(set) var isProcessingNotes = false + private(set) var processingStatus = "" + private(set) var elapsedTime: TimeInterval = 0 + private(set) var savedMeeting: Meeting? + private(set) var captureState: CaptureState = .idle + + var selectedTemplate = TemplateSettings.shared.defaultTemplate + var customInstructions = TemplateSettings.shared.customInstructions + var selectedLanguage = TranscriptionSettings.shared.defaultLanguage + var selectedSummaryLanguage = SummaryLanguageSettings.shared.defaultLanguage + var userNotesText = "" + var timestampedNotes: [TimestampedNote] = [] + + @ObservationIgnored private var modelContext: ModelContext? + @ObservationIgnored private var timer: Timer? + @ObservationIgnored private var backgroundActivity: RecordingActivity? + @ObservationIgnored private var captureDidStart = false + @ObservationIgnored private var isStarting = false + @ObservationIgnored private var elapsedBeforePause: TimeInterval = 0 + @ObservationIgnored private var runningSince: TimeInterval? + @ObservationIgnored private var lastCheckpointTime: TimeInterval = 0 + @ObservationIgnored private let now: () -> TimeInterval + @ObservationIgnored private var settingsSubscriptions = Set() + + init( + captureManager: AudioCaptureManager = AudioCaptureManager(), + transcriptionEngine: TranscriptionEngine = .shared, + diarizationEngine: NeuralDiarizationEngine = .shared, + now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { + self.captureManager = captureManager + self.transcriptionEngine = transcriptionEngine + self.diarizationEngine = diarizationEngine + self.now = now + captureManager.onStateChange = { [weak self] state in + self?.captureStateChanged(state) + } + // A settings window can change defaults when no recording view exists. + // Preserve choices during capture/saving, and update only a setup draft. + TemplateSettings.shared.$defaultTemplate.sink { [weak self] value in + guard let self, self.isInSetupPhase else { return } + self.selectedTemplate = value + }.store(in: &settingsSubscriptions) + TemplateSettings.shared.$customInstructions.sink { [weak self] value in + guard let self, self.isInSetupPhase else { return } + self.customInstructions = value + }.store(in: &settingsSubscriptions) + SummaryLanguageSettings.shared.$defaultLanguage.sink { [weak self] value in + guard let self, self.isInSetupPhase else { return } + self.selectedSummaryLanguage = value + }.store(in: &settingsSubscriptions) + TranscriptionSettings.shared.$defaultLanguage.sink { [weak self] value in + guard let self, self.isInSetupPhase else { return } + self.selectedLanguage = value + }.store(in: &settingsSubscriptions) + } + + var isRecording: Bool { captureState == .recording } + var isPaused: Bool { captureState == .paused } + /// Includes capture startup and a user-requested pause, but not finalization. + var isActive: Bool { ownsRecordingSession && !isProcessingNotes } + var isInSetupPhase: Bool { !ownsRecordingSession && savedMeeting == nil } + var shouldRestoreRecording: Bool { ownsRecordingSession || savedMeeting != nil } + var hasBackgroundActivity: Bool { backgroundActivity != nil } + + /// Only an explicit request for a new recording clears the preceding session. + func prepareNewRecording() { + guard !ownsRecordingSession, !transcriptionEngine.isSessionActive else { return } + savedMeeting = nil + userNotesText = "" + timestampedNotes = [] + elapsedTime = 0 + processingStatus = "" + selectedTemplate = TemplateSettings.shared.defaultTemplate + customInstructions = TemplateSettings.shared.customInstructions + selectedLanguage = TranscriptionSettings.shared.defaultLanguage + selectedSummaryLanguage = SummaryLanguageSettings.shared.defaultLanguage + transcriptionEngine.reset() + id = UUID() + } + + func startRecording(modelContext: ModelContext) { + guard isInSetupPhase, !transcriptionEngine.isSessionActive else { return } + if case .error = captureManager.state { + _ = captureManager.stopRecording() + } + self.modelContext = modelContext + transcriptionEngine.beginSession() + ownsRecordingSession = true + captureDidStart = false + isStarting = true + + timestampedNotes = userNotesText.components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .map { TimestampedNote(timestamp: 0, text: $0) } + + transcriptionEngine.selectedLanguage = selectedLanguage.whisperCode + if let code = selectedLanguage.whisperCode { + transcriptionEngine.detectedLanguage = code + } + captureManager.onAudioSamples = { [weak transcriptionEngine] samples in + Task { @MainActor in + transcriptionEngine?.appendAudio(samples) + } + } + + elapsedTime = 0 + elapsedBeforePause = 0 + lastCheckpointTime = 0 + runningSince = now() + beginBackgroundActivity() + startTimer() + captureManager.startRecording() + } + + func pauseRecording() { + guard isActive, isRecording, !isStarting else { return } + updateElapsedTime() + elapsedBeforePause = elapsedTime + runningSince = nil + captureManager.pauseRecording() + stopTimer() + backgroundActivity = nil + } + + func resumeRecording() { + guard isActive, isPaused, !isStarting else { return } + isStarting = true + runningSince = now() + beginBackgroundActivity() + startTimer() + captureManager.resumeRecording() + } + + /// Called directly by AppDelegate, including when the last window is closed. + func stopForTermination() { + guard ownsRecordingSession, !isProcessingNotes else { return } + if captureDidStart { + stopRecording() + } else { + releaseRecordingSession() + } + } + + private func captureStateChanged(_ state: CaptureState) { + captureState = state + if case .recording = state { + captureDidStart = true + isStarting = false + } + if case .error = state, ownsRecordingSession, !isProcessingNotes { + isStarting = false + if captureDidStart { + logger.notice("Capture failed after \(self.elapsedTime)s; saving the session") + stopRecording() + } else { + releaseRecordingSession() + } + } + } + + private func beginBackgroundActivity() { + if backgroundActivity == nil { + backgroundActivity = RecordingActivity() + } + } + + private func startTimer() { + stopTimer() + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.updateElapsedTime() } + } + self.timer = timer + // Menus and window interactions must not suspend elapsed-time updates. + RunLoop.main.add(timer, forMode: .common) + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + /// Derive time from a monotonic clock, never from the number of timer fires. + /// A delayed callback while the app is hidden must not lose elapsed seconds. + func updateElapsedTime() { + guard let runningSince else { return } + elapsedTime = elapsedBeforePause + max(0, now() - runningSince) + if elapsedTime - lastCheckpointTime >= 300 { + lastCheckpointTime = elapsedTime + checkpoint() + } + } + + private func checkpoint() { + guard let config = modelContext?.container.configurations.first, + !config.isStoredInMemoryOnly else { return } + let ok = DatabaseCheckpoint.performCheckpoint(at: config.url, mode: .passive) + logger.notice("Recording checkpoint success=\(ok, privacy: .public)") + } + + private func releaseRecordingSession() { + captureManager.abortCapture() + captureManager.onAudioSamples = nil + stopTimer() + runningSince = nil + backgroundActivity = nil + transcriptionEngine.endSession() + ownsRecordingSession = false + isStarting = false + AppDelegate.resolveTerminationIfIdle() + } + + @discardableResult + func stopRecording() -> Task? { + guard ownsRecordingSession, !isProcessingNotes, let modelContext else { return nil } + // A stop during Core Audio startup must invalidate the in-flight start. + guard captureDidStart else { + releaseRecordingSession() + return nil + } + updateElapsedTime() + runningSince = nil + stopTimer() + isStarting = false + isProcessingNotes = true + let recording = captureManager.stopRecording() + let duration = elapsedTime + + // Saving must survive App Nap and a quit request even after capture stops. + beginBackgroundActivity() + AppDelegate.beginSavePipeline() + return Task { + defer { + isProcessingNotes = false + releaseRecordingSession() + AppDelegate.endSavePipeline() + } + // 1. Finalize transcription + processingStatus = "Finalizing transcription..." + let segments = await transcriptionEngine.finalizeTranscription() + logger.info("Transcription finalized: \(segments.count) segments") + for (i, seg) in segments.prefix(5).enumerated() { + logger.info(" Segment[\(i)]: \(String(format: "%.2f", seg.startTime))-\(String(format: "%.2f", seg.endTime))s \"\(String(seg.text.prefix(60)))\"") + } + + // 2. Detect language (acoustic analysis, independent of transcription text) + if transcriptionEngine.detectedLanguage == nil { + let languageDetectionSource = recording.systemSamples.isEmpty + ? recording.combinedSamples + : recording.systemSamples + let detectSamples = Array(languageDetectionSource.prefix(Int(16000 * 30))) + await transcriptionEngine.detectLanguage(detectSamples) + } + + // 3. Diarize + processingStatus = "Identifying speakers..." + let diarizeResult = await diarizationEngine.diarize( + segments: segments, + systemSamples: recording.systemSamples, + micSamples: recording.micSamples, + detectedLanguage: transcriptionEngine.detectedLanguage + ) + let diarizedSegments = diarizeResult.segments + + // Log diarization results + let speakers = Set(diarizedSegments.compactMap(\.speaker)) + logger.info("Diarization complete: \(diarizedSegments.count) segments, speakers: \(speakers.sorted()), embeddings: \(diarizeResult.speakerEmbeddings.count)") + + // 4. Create transcript + let transcript = Transcript( + rawText: transcriptionEngine.liveText, + segments: diarizedSegments + ) + logger.info("Transcript created. rawText length: \(transcript.rawText.count), segments: \(transcript.segments.count)") + + // 5. Rebuild timestampedNotes from the final notepad so it faithfully + // mirrors the notes the user actually kept (see TimestampedNote.reconcile). + // Enhancement and markdown export prefer it over the raw text, so it + // must contain every kept line and nothing stale, even after the user + // edits, deletes, or duplicates lines mid-session. + let trimmedNotes = userNotesText.trimmingCharacters(in: .whitespacesAndNewlines) + timestampedNotes = TimestampedNote.reconcile( + notepadText: userNotesText, + log: timestampedNotes, + trailingTimestamp: elapsedTime + ) + + // 6. Save session + let sessionTitle = "Session \(DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .short))" + let meeting = Meeting( + title: sessionTitle, + date: captureManager.recordingStartTime ?? Date(), + duration: duration, + appSource: captureManager.selectedProcess?.name, + appBundleID: captureManager.selectedProcess?.bundleID + ) + meeting.transcript = transcript + transcript.meeting = meeting + + // Save user notes and timestamps + meeting.userNotesText = trimmedNotes.isEmpty ? nil : trimmedNotes + meeting.timestampedNotes = timestampedNotes.isEmpty ? nil : timestampedNotes + + // Save speaker embeddings for lightweight re-clustering (~500KB vs ~115MB raw audio) + meeting.speakerEmbeddings = diarizeResult.speakerEmbeddings + meeting.originalSpeakerCount = speakers.count + meeting.originalSegmentsData = transcript.segmentsData + meeting.detectedLanguage = transcriptionEngine.detectedLanguage + + modelContext.insert(meeting) + try? modelContext.save() + checkpoint() + savedMeeting = meeting + } + } +} + +/// The assertion permits display sleep, while preventing App Nap and idle +/// system sleep for capture/finalization. Releasing it also handles early exits. +private final class RecordingActivity { + private let token = ProcessInfo.processInfo.beginActivity( + options: .userInitiated, + reason: "Recording and saving meeting audio" + ) + + deinit { + ProcessInfo.processInfo.endActivity(token) + } +} diff --git a/Sources/SeminarlyApp.swift b/Sources/SeminarlyApp.swift index 1dea392..287cb30 100644 --- a/Sources/SeminarlyApp.swift +++ b/Sources/SeminarlyApp.swift @@ -5,18 +5,22 @@ import os private let logger = Logger(subsystem: "ai.seminarly.Seminarly", category: "Database") +@MainActor @Observable final class AppState { - var isRecording = false - var isPaused = false - var recordingElapsedTime: TimeInterval = 0 + let recordingSession: RecordingSession + + init(recordingSession: RecordingSession = RecordingSession()) { + self.recordingSession = recordingSession + } + + var isRecording: Bool { recordingSession.isActive } + var isPaused: Bool { recordingSession.isPaused } + var recordingElapsedTime: TimeInterval { recordingSession.elapsedTime } } extension Notification.Name { - /// Posted by AppDelegate when the user quits while a recording is active: - /// the live RecordingView stops and saves the session, and termination - /// completes once the save pipeline lands. - static let seminarlyStopRecordingForTermination = Notification.Name("ai.seminarly.stopRecordingForTermination") + static let seminarlyShowRecording = Notification.Name("ai.seminarly.showRecording") } @Observable @@ -45,9 +49,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private static var activeSavePipelines = 0 @MainActor private static var terminationPending = false + @MainActor static weak var recordingSession: RecordingSession? @MainActor static var hasActiveRecordingWork: Bool { - activeSavePipelines > 0 || TranscriptionEngine.shared.isSessionActive + activeSavePipelines > 0 || recordingSession?.ownsRecordingSession == true + || TranscriptionEngine.shared.isSessionActive } /// Bracket a finalize/save pipeline so Cmd+Q / menu-bar Quit waits for the @@ -66,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor static func resolveTerminationIfIdle() { guard terminationPending, activeSavePipelines <= 0, + recordingSession?.ownsRecordingSession != true, !TranscriptionEngine.shared.isSessionActive else { return } terminationPending = false @@ -79,25 +86,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Self.terminationPending = true return .terminateLater } - if TranscriptionEngine.shared.isSessionActive { + if Self.recordingSession?.ownsRecordingSession == true { // A recording is live and no pipeline exists yet — quitting now - // would discard it. Ask the recording UI to stop and save - // (delivered synchronously; stopRecording registers its - // pipeline before this returns), then terminate when it lands. + // would discard it. Ask the app-owned session to stop and save + // and terminate when its save pipeline lands. logger.notice("Delaying termination: active recording session — stopping and saving first") Self.terminationPending = true - NotificationCenter.default.post(name: .seminarlyStopRecordingForTermination, object: nil) - // Watchdog: if nothing picked the request up (no live recording - // view — shouldn't happen, but quit must never hang), give up - // waiting and terminate. - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - MainActor.assumeIsolated { - if Self.terminationPending, Self.activeSavePipelines <= 0 { - logger.error("Termination watchdog fired — no save pipeline started; quitting anyway") - Self.terminationPending = false - NSApplication.shared.reply(toApplicationShouldTerminate: true) - } - } + // Schedule after returning .terminateLater, so even a cancelled + // startup cannot reply before AppKit starts waiting for us. + Task { @MainActor in + Self.recordingSession?.stopForTermination() + Self.resolveTerminationIfIdle() } return .terminateLater } @@ -105,6 +104,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + func applicationWillTerminate(_ notification: Notification) { CodexProcessRegistry.shared.terminateAll() let success = DatabaseCheckpoint.performCheckpoint(at: Self.storeURL, mode: .truncate) @@ -123,6 +126,7 @@ struct SeminarlyApp: App { let appState = AppState() init() { + defer { AppDelegate.recordingSession = appState.recordingSession } do { let migrationResult = try DatabaseStore.migrateLegacyStoreIfNeeded() if case .migrated(let meetingCount) = migrationResult { diff --git a/Sources/Transcription/TranscriptionEngine.swift b/Sources/Transcription/TranscriptionEngine.swift index 7223854..d85c9dd 100644 --- a/Sources/Transcription/TranscriptionEngine.swift +++ b/Sources/Transcription/TranscriptionEngine.swift @@ -335,7 +335,7 @@ final class TranscriptionEngine: ObservableObject { } /// Release the engine after the post-stop pipeline has consumed its output - /// (or after the recording view died mid-recording and no pipeline will run), + /// (or after capture failed to start and no pipeline will run), /// then apply any model switch that was requested during the session. func endSession() { isSessionActive = false diff --git a/Sources/Views/ContentView.swift b/Sources/Views/ContentView.swift index fb0a88a..ae29cd7 100644 --- a/Sources/Views/ContentView.swift +++ b/Sources/Views/ContentView.swift @@ -8,13 +8,6 @@ struct ContentView: View { @Query(sort: \Meeting.date, order: .reverse) private var meetings: [Meeting] @State private var selectedMeeting: Meeting? @State private var showingRecording = false - // Bumped every time a *new* recording is started, to re-key RecordingView so - // SwiftUI rebuilds it fresh (see presentRecording()). - @State private var recordingSessionID = 0 - // True while THIS window's RecordingView sits in its post-recording (saved) - // state. Per-window (not on the shared AppState) so another window's - // RecordingView lifecycle can't clobber it. - @State private var recordingSaved = false @State private var showingSettings = false @State private var searchText = "" @State private var showDeleteConfirmation = false @@ -130,7 +123,7 @@ struct ContentView: View { ToolbarItem(placement: .primaryAction) { if appState.isRecording && !viewingRecording { Button { - viewingRecording = true + presentRecording() } label: { HStack(spacing: Spacing.xxs + 2) { BreathingDot(isPaused: appState.isPaused) @@ -159,9 +152,13 @@ struct ContentView: View { } } .background(SeminarlyColors.background) - .onChange(of: appState.isRecording) { _, isRec in + .onChange(of: appState.isRecording, initial: true) { _, isRec in audioMonitor.isRecordingActive = isRec } + .onReceive(NotificationCenter.default.publisher(for: .seminarlyShowRecording)) { _ in + showingRecording = true + viewingRecording = true + } .onChange(of: columnVisibility) { _, newValue in if newValue == .detailOnly { showSidebarTools = false @@ -208,6 +205,10 @@ struct ContentView: View { } } .onAppear { + if appState.recordingSession.shouldRestoreRecording { + showingRecording = true + viewingRecording = true + } if LLMSettings.shared.currentDescriptor.kind == .chatGPTPlan { ChatGPTAccountStore.shared.refresh() } if case .recoveredByQuarantine = databaseState.error { showRecoveryNoticeAlert = true @@ -333,9 +334,10 @@ struct ContentView: View { if showingRecording { RecordingView( selectedMeeting: $selectedMeeting, + captureManager: appState.recordingSession.captureManager, + session: appState.recordingSession, transcriptionEngine: transcriptionEngine, diarizationEngine: diarizationEngine, - recordingSaved: $recordingSaved, audioMonitor: audioMonitor, preSelectedProcess: preSelectedProcess, isVisible: viewingRecording, @@ -349,7 +351,7 @@ struct ContentView: View { viewingRecording = false } ) - .id(recordingSessionID) + .id(appState.recordingSession.id) .opacity(viewingRecording ? 1 : 0) .allowsHitTesting(viewingRecording) } @@ -443,21 +445,11 @@ struct ContentView: View { } } - /// Brings the recording view forward. If a *finished* recording is still - /// mounted — because the user navigated away from the saved screen (tapping - /// another session / Settings) without pressing Done, which leaves - /// `showingRecording` true — bumping `recordingSessionID` re-keys RecordingView - /// so SwiftUI rebuilds it from scratch; otherwise the stale "Recording saved" - /// screen would just be re-revealed instead of a new recording. - /// - /// We re-key *only* in that saved state (this window's `recordingSaved`). - /// A setup view (unsaved notes/chip selections), an active capture, and a - /// stopped session still finalizing on its background Task (which shares - /// transcriptionEngine) are all left mounted and merely re-revealed, never - /// torn down mid-flight. + /// Every window reconnects to the app-owned session. Only an explicit new + /// recording request after saving clears it and rebuilds the setup controls. private func presentRecording() { - if recordingSaved { - recordingSessionID += 1 + if appState.recordingSession.savedMeeting != nil { + appState.recordingSession.prepareNewRecording() } showingRecording = true viewingRecording = true @@ -487,6 +479,10 @@ struct ContentView: View { // the model we're about to delete. EnhancementCoordinator.shared.cancel(meeting) + if appState.recordingSession.savedMeeting == meeting { + appState.recordingSession.prepareNewRecording() + } + meeting.deleteAudioFiles() modelContext.delete(meeting) try? modelContext.save() diff --git a/Sources/Views/MenuBarView.swift b/Sources/Views/MenuBarView.swift index bf4ea46..353420d 100644 --- a/Sources/Views/MenuBarView.swift +++ b/Sources/Views/MenuBarView.swift @@ -8,13 +8,25 @@ struct MenuBarView: View { VStack(spacing: Spacing.xxs) { if appState.isRecording { Button { - // Open main window to stop recording NSApplication.shared.activate(ignoringOtherApps: true) openMainWindow() + NotificationCenter.default.post(name: .seminarlyShowRecording, object: nil) } label: { - Label("Recording in progress...", systemImage: "record.circle.fill") + Label(appState.isPaused ? "Recording paused" : "Recording in progress...", systemImage: "record.circle.fill") .foregroundStyle(SeminarlyColors.recording) } + + Button(appState.isPaused ? "Resume Recording" : "Pause Recording") { + if appState.isPaused { + appState.recordingSession.resumeRecording() + } else { + appState.recordingSession.pauseRecording() + } + } + Button("Stop Recording") { + appState.recordingSession.stopRecording() + } + Divider() } Button { @@ -37,6 +49,9 @@ struct MenuBarView: View { private func openMainWindow() { for window in NSApplication.shared.windows { if window.title == "Seminarly" || window.canBecomeMain { + if window.isMiniaturized { + window.deminiaturize(nil) + } window.makeKeyAndOrderFront(nil) return } diff --git a/Sources/Views/RecordingView.swift b/Sources/Views/RecordingView.swift index 6fc14c6..0072a87 100644 --- a/Sources/Views/RecordingView.swift +++ b/Sources/Views/RecordingView.swift @@ -9,17 +9,12 @@ struct RecordingView: View { @Environment(AppState.self) private var appState @Binding var selectedMeeting: Meeting? - @StateObject private var captureManager = AudioCaptureManager() + @ObservedObject var captureManager: AudioCaptureManager + @Bindable var session: RecordingSession @ObservedObject var transcriptionEngine: TranscriptionEngine @ObservedObject var diarizationEngine: NeuralDiarizationEngine - /// True while this view sits in its post-recording (saved) state, i.e. - /// `savedMeeting` is set. Owned per-window by ContentView (which re-keys a - /// *finished* recording on the next "Record") — a global flag here would let - /// one window's teardown clobber another window's saved-screen state. - @Binding var recordingSaved: Bool @ObservedObject private var enhancement = EnhancementCoordinator.shared - @ObservedObject private var templateSettings = TemplateSettings.shared @ObservedObject private var summaryLanguageSettings = SummaryLanguageSettings.shared @ObservedObject var audioMonitor: AudioSourceMonitor @@ -35,48 +30,17 @@ struct RecordingView: View { /// Called when the user wants to navigate away while keeping recording alive. var onNavigateAway: () -> Void = {} - @State private var isProcessingNotes = false - @State private var processingStatus = "" - @State private var elapsedTime: TimeInterval = 0 - @State private var pausedDuration: TimeInterval = 0 - @State private var pauseStartTime: Date? - @State private var timer: Timer? - @State private var selectedTemplate: NoteTemplate = TemplateSettings.shared.defaultTemplate - @State private var customInstructions: String = TemplateSettings.shared.customInstructions - @State private var selectedLanguage: TranscriptionLanguage = TranscriptionSettings.shared.defaultLanguage - @State private var selectedSummaryLanguage: SummaryLanguage = SummaryLanguageSettings.shared.defaultLanguage @State private var summaryLanguageCustomDraft: String = SummaryLanguageSettings.shared.lastCustomLanguage @State private var showSummaryLanguageCustomEditor: Bool = false @State private var showRegenerateSheet: Bool = false - // Notepad state - @State private var userNotesText = "" @State private var showTranscript = true - // Live, append-only log of notes completed during recording. It can drift - // from the editable notepad as the user edits/deletes lines, so it is - // rebuilt from the final notepad text in stopRecording() before saving. - @State private var timestampedNotes: [TimestampedNote] = [] - - // Post-recording lifecycle state (set after stopRecording saves the meeting) - @State private var savedMeeting: Meeting? - - // True from Record-click until this view's recording session is released - // (saved, failed, or torn down). Unlike isRecording/isPaused — which derive - // from captureManager.state and are false both before capture actually - // starts and after a capture error — this tracks session OWNERSHIP, so - // cleanup can't be skipped in those states (which would leak the app-wide - // recording flags and the shared engine's session forever). - @State private var ownsRecordingSession = false - // True once capture actually reached .recording for this attempt — a - // start-failure has elapsed-timer ticks but no audio, and must not be - // salvaged as an (empty) meeting. - @State private var captureDidStart = false var body: some View { VStack(spacing: 0) { - if let meeting = savedMeeting { + if let meeting = session.savedMeeting { postRecordingView(meeting: meeting) - } else if isRecording || isPaused || isProcessingNotes { + } else if session.isActive || session.isProcessingNotes { activeRecordingView } else { setupView @@ -85,7 +49,7 @@ struct RecordingView: View { .background(SeminarlyColors.background) .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $showRegenerateSheet) { - if let meeting = savedMeeting { + if let meeting = session.savedMeeting { RegenerateNotesSheet( initialTemplate: initialRegenerateTemplate(for: meeting), initialLanguage: initialRegenerateLanguage(for: meeting), @@ -101,7 +65,7 @@ struct RecordingView: View { .toolbar { ToolbarItem(placement: .navigation) { Button { - if isRecording || isPaused { + if session.isActive { onNavigateAway() } else { onDismiss() @@ -113,98 +77,17 @@ struct RecordingView: View { } } .task { - // A freshly built view starts in setup (savedMeeting == nil), so it's - // not a saved recording until stopRecording() finishes. - recordingSaved = false - // The engine is shared app-wide: a setup view opening in another - // window must not wipe a recording that is live elsewhere or a - // stopped one whose finalization is still consuming the engine. - if !appState.isRecording && !transcriptionEngine.isSessionActive { - transcriptionEngine.reset() - } + // Reopening a window observes the same session; it must never reset + // the engine, notes, or source selection of a background recording. + guard session.isInSetupPhase else { return } captureManager.refreshProcessList() if let preSelectedProcess { captureManager.selectedProcess = preSelectedProcess } } - .onDisappear { - // This instance is leaving the hierarchy (dismissed or rebuilt), so - // no saved recording is mounted anymore. - recordingSaved = false - // If the view is torn down while it owns a session whose pipeline - // hasn't started (window closed mid-recording, mid-start, or after a - // capture error), its captureManager dies with it and no pipeline - // would run. Salvage a real recording by stopping capture and running - // the normal finalize/save pipeline (its Task outlives this view); - // otherwise just clear the app-wide flags and release the engine so - // the menu bar and other windows don't report a phantom recording - // forever. When the pipeline IS already running (isProcessingNotes), - // it survives this view and releases the session itself. - if ownsRecordingSession && !isProcessingNotes { - // isRecording/isPaused prove capture actually started — salvage - // even inside the first second (the elapsed timer only ticks at - // 1s, and the user's setup notes are part of the saved session). - if isRecording || isPaused { - logger.notice("Window closed mid-recording after \(Int(elapsedTime))s — finalizing and saving the session") - stopRecording(viewWillPersist: false) - } else { - releaseRecordingSession() - } - } - } - .onReceive(NotificationCenter.default.publisher(for: .seminarlyStopRecordingForTermination)) { _ in - // The user quit while this recording is live: stop and save so - // termination (held by AppDelegate) can complete with the session - // preserved. Mirrors the window-close teardown policy. - guard ownsRecordingSession, !isProcessingNotes else { return } - if isRecording || isPaused { - logger.notice("App terminating mid-recording after \(Int(elapsedTime))s — finalizing and saving the session") - stopRecording(viewWillPersist: false) - } else { - releaseRecordingSession() - } - } - .onChange(of: captureManager.state) { _, newState in - if case .recording = newState { - captureDidStart = true - } - // Capture failed to start (or died): isRecording/isPaused turn false - // so the Stop button is unreachable, yet the flags set optimistically - // in startRecording() would block every retry and model swap forever. - // If capture actually reached .recording, real samples were delivered - // — salvage them through the normal finalize/save pipeline (the same - // capture-started ⇒ salvage policy as window close and app quit), - // even inside the first second. Only a start that never produced - // audio releases the session so the error banner's Retry can start - // over. captureDidStart, not wall-clock: the timer starts before - // Core Audio finishes its async setup, so a slow start-failure has - // ticks but zero samples. - if case .error = newState, ownsRecordingSession, !isProcessingNotes { - if captureDidStart { - logger.notice("Capture error after \(Int(elapsedTime))s of recording — finalizing and saving the session") - stopRecording() - } else { - releaseRecordingSession() - } - } - } - // Sync local chip selection with Settings changes while still in the setup - // phase. RecordingView is kept alive behind an opacity layer when the user - // navigates to Settings, so the @State initializer only runs once — without - // this, changing the default in Settings would not update the chip. - .onChange(of: templateSettings.defaultTemplate) { _, newValue in - if isInSetupPhase { - selectedTemplate = newValue - } - } - .onChange(of: templateSettings.customInstructions) { _, newValue in - if isInSetupPhase { - customInstructions = newValue - } - } - .onChange(of: summaryLanguageSettings.defaultLanguage) { _, newValue in - if isInSetupPhase { - selectedSummaryLanguage = newValue + .onChange(of: session.savedMeeting?.id, initial: true) { _, _ in + if let meeting = session.savedMeeting { + selectedMeeting = meeting } } // Auto-select newly-detected audio sources while the user is in the @@ -226,7 +109,7 @@ struct RecordingView: View { /// True when the user hasn't started (or finished) a recording yet — safe to /// pull fresh defaults from Settings. private var isInSetupPhase: Bool { - savedMeeting == nil && !isRecording && !isPaused && !isProcessingNotes + session.isInSetupPhase } // MARK: - Phase 1: Setup View (before recording) @@ -241,7 +124,7 @@ struct RecordingView: View { Divider() - if selectedTemplate == .custom { + if session.selectedTemplate == .custom { customInstructionsEditor Divider() } @@ -254,7 +137,7 @@ struct RecordingView: View { } NotepadSurface( - userNotesText: $userNotesText, + userNotesText: $session.userNotesText, structuredNote: nil, placeholderTitle: "Jot down your agenda or questions before the session starts...", placeholderSubtitle: "Use # for headings" @@ -273,28 +156,28 @@ struct RecordingView: View { Divider() NotepadSurface( - userNotesText: $userNotesText, + userNotesText: $session.userNotesText, structuredNote: nil, - isEditable: !isProcessingNotes, + isEditable: !session.isProcessingNotes, autoFocus: true, placeholderTitle: "Type notes as you listen...", placeholderSubtitle: "Use # headings to define sections", onLineCompleted: { lineText in // Append freely — any duplicate of a seeded setup line is - // reconciled when stopRecording() rebuilds from the notepad. - timestampedNotes.append( - TimestampedNote(timestamp: elapsedTime, text: lineText) + // reconciled when session.stopRecording() rebuilds from the notepad. + session.timestampedNotes.append( + TimestampedNote(timestamp: session.elapsedTime, text: lineText) ) } ) - if isProcessingNotes { + if session.isProcessingNotes { Divider() processingSection .padding(Spacing.md) } - if showTranscript && !isProcessingNotes { + if showTranscript && !session.isProcessingNotes { Divider() liveTranscriptFooter } @@ -313,7 +196,7 @@ struct RecordingView: View { Divider() NotepadSurface( - userNotesText: $userNotesText, + userNotesText: $session.userNotesText, structuredNote: meeting.structuredNote, isEditable: meeting.structuredNote == nil, placeholderTitle: "No notes typed during recording", @@ -337,7 +220,7 @@ struct RecordingView: View { } } } - .onChange(of: userNotesText) { _, newValue in + .onChange(of: session.userNotesText) { _, newValue in guard meeting.structuredNote == nil else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let next = trimmed.isEmpty ? nil : trimmed @@ -447,7 +330,7 @@ struct RecordingView: View { Spacer() // Transcript toggle - if !isProcessingNotes { + if !session.isProcessingNotes { Button { withAnimation(.easeInOut(duration: 0.2)) { showTranscript.toggle() @@ -464,7 +347,7 @@ struct RecordingView: View { // Pause / Resume if isRecording { Button { - pauseRecording() + session.pauseRecording() } label: { Image(systemName: "pause.circle.fill") .font(.system(size: 20)) @@ -474,7 +357,7 @@ struct RecordingView: View { .help("Pause recording") } else if isPaused { Button { - resumeRecording() + session.resumeRecording() } label: { Image(systemName: "play.circle.fill") .font(.system(size: 20)) @@ -485,9 +368,9 @@ struct RecordingView: View { } // Stop - if isRecording || isPaused { + if session.isActive { Button { - stopRecording() + session.stopRecording() } label: { Image(systemName: "stop.circle.fill") .font(.system(size: 20)) @@ -606,24 +489,24 @@ struct RecordingView: View { Menu { ForEach(NoteTemplate.allCases) { template in Button { - selectedTemplate = template + session.selectedTemplate = template } label: { Label(template.displayName, systemImage: template.icon) } } } label: { - chipLabel(icon: selectedTemplate.icon, text: selectedTemplate.displayName, compact: compact) + chipLabel(icon: session.selectedTemplate.icon, text: session.selectedTemplate.displayName, compact: compact) } .menuStyle(.borderlessButton) .fixedSize() - .help("\(selectedTemplate.displayName): \(selectedTemplate.description)") + .help("\(session.selectedTemplate.displayName): \(session.selectedTemplate.description)") } private func languageChip(compact: Bool) -> some View { Menu { ForEach(TranscriptionLanguage.allCases) { lang in Button { - selectedLanguage = lang + session.selectedLanguage = lang } label: { if lang == .auto { Text(lang.displayName) @@ -633,26 +516,26 @@ struct RecordingView: View { } } } label: { - chipLabel(icon: "globe", text: selectedLanguage.displayName, compact: compact) + chipLabel(icon: "globe", text: session.selectedLanguage.displayName, compact: compact) } .menuStyle(.borderlessButton) .fixedSize() - .help(selectedLanguage == .auto + .help(session.selectedLanguage == .auto ? "Language: auto-detect per chunk" - : "Language: \(selectedLanguage.displayName)") + : "Language: \(session.selectedLanguage.displayName)") } private func summaryLanguageChip(compact: Bool) -> some View { Menu { Button { - selectedSummaryLanguage = .matchTranscript + session.selectedSummaryLanguage = .matchTranscript } label: { Text(SummaryLanguage.matchTranscript.displayName) } Divider() ForEach(SummaryLanguage.presets, id: \.rawValue) { lang in Button { - selectedSummaryLanguage = lang + session.selectedSummaryLanguage = lang } label: { Text("\(lang.displayName) (\(lang.nativeName))") } @@ -666,7 +549,7 @@ struct RecordingView: View { } label: { chipLabel( icon: "text.bubble", - text: "Notes: \(selectedSummaryLanguage.displayName)", + text: "Notes: \(session.selectedSummaryLanguage.displayName)", compact: compact ) } @@ -687,7 +570,7 @@ struct RecordingView: View { Button("Use") { let trimmed = summaryLanguageCustomDraft.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { - selectedSummaryLanguage = .custom(trimmed) + session.selectedSummaryLanguage = .custom(trimmed) summaryLanguageSettings.lastCustomLanguage = trimmed } showSummaryLanguageCustomEditor = false @@ -701,13 +584,13 @@ struct RecordingView: View { } private var summaryLanguageHelpText: String { - switch selectedSummaryLanguage { + switch session.selectedSummaryLanguage { case .matchTranscript: return "Notes language: same as transcript" case .custom(let name): return "Notes language: \(name)" default: - return "Notes language: \(selectedSummaryLanguage.displayName)" + return "Notes language: \(session.selectedSummaryLanguage.displayName)" } } @@ -885,7 +768,7 @@ struct RecordingView: View { Image(systemName: "square.and.pencil") .font(.system(size: 11)) .foregroundStyle(SeminarlyColors.textSecondary) - TextField("Custom note generation instructions...", text: $customInstructions) + TextField("Custom note generation instructions...", text: $session.customInstructions) .font(Typography.caption) .textFieldStyle(.plain) } @@ -894,7 +777,7 @@ struct RecordingView: View { @ViewBuilder private var processingSection: some View { - if isProcessingNotes { + if session.isProcessingNotes { VStack(alignment: .leading, spacing: Spacing.xs) { Text("Processing") .font(Typography.headline) @@ -903,7 +786,7 @@ struct RecordingView: View { HStack { ProgressView() .controlSize(.small) - Text(processingStatus) + Text(session.processingStatus) .font(Typography.body) .foregroundStyle(SeminarlyColors.textSecondary) } @@ -915,263 +798,29 @@ struct RecordingView: View { // MARK: - Logic - private var isRecording: Bool { - if case .recording = captureManager.state { return true } - return false - } + private var isRecording: Bool { session.isRecording } - private var isPaused: Bool { - if case .paused = captureManager.state { return true } - return false - } + private var isPaused: Bool { session.isPaused } private var formattedElapsedTime: String { - let minutes = Int(elapsedTime) / 60 - let seconds = Int(elapsedTime) % 60 + let minutes = Int(session.elapsedTime) / 60 + let seconds = Int(session.elapsedTime) % 60 return String(format: "%02d:%02d", minutes, seconds) } - /// Undo startRecording()'s optimistic claims after the recording can no - /// longer complete (capture error, or view teardown before the pipeline). - private func releaseRecordingSession() { - // Kill live taps AND any capture start still inside its detached Core - // Audio setup — otherwise a torn-down view can leave capture running - // headless. Deliberately state-preserving so an .error stays visible. - captureManager.abortCapture() - timer?.invalidate() - timer = nil - appState.isRecording = false - appState.isPaused = false - transcriptionEngine.endSession() - ownsRecordingSession = false - // If a quit is waiting on this session and no pipeline will run - // (nothing worth saving), let termination complete now. - AppDelegate.resolveTerminationIfIdle() - } - private func startRecording() { - guard !appState.isRecording, !transcriptionEngine.isSessionActive else { return } - // A failed attempt leaves the capture manager in .error, and its - // startRecording() silently no-ops unless .idle — reset it first so - // Retry actually retries instead of claiming flags over dead capture. - if case .error = captureManager.state { - _ = captureManager.stopRecording() - } - transcriptionEngine.beginSession() - ownsRecordingSession = true - captureDidStart = false - - // Preserve any notes the user jotted down during the setup phase rather - // than wiping them, and seed each non-empty line as a 0:00 entry. This - // anchors pre-meeting notes at the start of the timeline: stopRecording() - // rebuilds timestampedNotes from the final notepad and keeps each line's - // earliest stamp, so a seeded setup line stays at 0:00 even if the user - // later presses Enter after it (which would otherwise stamp it mid-session). - let setupLines = userNotesText - .components(separatedBy: "\n") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - timestampedNotes = setupLines.map { TimestampedNote(timestamp: 0, text: $0) } - - // Apply user-selected language - transcriptionEngine.selectedLanguage = selectedLanguage.whisperCode - if let code = selectedLanguage.whisperCode { - transcriptionEngine.detectedLanguage = code - } - - captureManager.onAudioSamples = { [weak transcriptionEngine] samples in - Task { @MainActor in - transcriptionEngine?.appendAudio(samples) - } - } - - captureManager.startRecording() - - elapsedTime = 0 - pausedDuration = 0 - pauseStartTime = nil - appState.recordingElapsedTime = 0 - appState.isPaused = false - appState.isRecording = true - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [self] _ in - MainActor.assumeIsolated { - elapsedTime += 1 - appState.recordingElapsedTime = elapsedTime - // Checkpoint every 5 minutes during long sessions so the WAL stays - // small and a forced quit can't lose an entire multi-hour recording. - if Int(elapsedTime) > 0 && Int(elapsedTime) % 300 == 0 { - let ok = DatabaseCheckpoint.performCheckpoint(at: AppDelegate.storeURL, mode: .passive) - logger.notice("5-min recording checkpoint fired at elapsed=\(Int(elapsedTime))s success=\(ok, privacy: .public)") - } - } - } - } - - private func pauseRecording() { - captureManager.pauseRecording() - timer?.invalidate() - timer = nil - pauseStartTime = Date() - appState.isPaused = true - } - - private func resumeRecording() { - if let pauseStart = pauseStartTime { - pausedDuration += Date().timeIntervalSince(pauseStart) - pauseStartTime = nil - } - captureManager.resumeRecording() - appState.isPaused = false - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [self] _ in - MainActor.assumeIsolated { - elapsedTime += 1 - appState.recordingElapsedTime = elapsedTime - if Int(elapsedTime) > 0 && Int(elapsedTime) % 300 == 0 { - let ok = DatabaseCheckpoint.performCheckpoint(at: AppDelegate.storeURL, mode: .passive) - logger.notice("5-min recording checkpoint fired at elapsed=\(Int(elapsedTime))s success=\(ok, privacy: .public)") - } - } - } - } - - /// Stop capture and run the finalize/diarize/save pipeline. Pass - /// `viewWillPersist: false` when this view is being torn down (window - /// closed mid-recording): the pipeline Task still completes and saves the - /// meeting, but skips the UI-state writes that only make sense for a - /// still-mounted view (saved screen, sidebar selection, re-key flag). - private func stopRecording(viewWillPersist: Bool = true) { - // Accumulate any final paused duration - if let pauseStart = pauseStartTime { - pausedDuration += Date().timeIntervalSince(pauseStart) - pauseStartTime = nil - } - timer?.invalidate() - timer = nil - appState.isRecording = false - appState.isPaused = false - - let recording = captureManager.stopRecording() - let duration = captureManager.recordingDuration - pausedDuration - - logger.info("Recording stopped. Duration: \(String(format: "%.1f", duration))s, systemSamples: \(recording.systemSamples.count), micSamples: \(recording.micSamples?.count ?? 0)") - - isProcessingNotes = true - - // Quitting must wait for this pipeline: the recording exists only in - // memory until modelContext.save() below. - AppDelegate.beginSavePipeline() - - Task { - defer { AppDelegate.endSavePipeline() } - - // 1. Finalize transcription - processingStatus = "Finalizing transcription..." - let segments = await transcriptionEngine.finalizeTranscription() - logger.info("Transcription finalized: \(segments.count) segments") - for (i, seg) in segments.prefix(5).enumerated() { - logger.info(" Segment[\(i)]: \(String(format: "%.2f", seg.startTime))-\(String(format: "%.2f", seg.endTime))s \"\(String(seg.text.prefix(60)))\"") - } - - // 2. Detect language (acoustic analysis, independent of transcription text) - if transcriptionEngine.detectedLanguage == nil { - let languageDetectionSource = recording.systemSamples.isEmpty - ? recording.combinedSamples - : recording.systemSamples - let detectSamples = Array(languageDetectionSource.prefix(Int(16000 * 30))) - await transcriptionEngine.detectLanguage(detectSamples) - } - - // 3. Diarize - processingStatus = "Identifying speakers..." - let diarizeResult = await diarizationEngine.diarize( - segments: segments, - systemSamples: recording.systemSamples, - micSamples: recording.micSamples, - detectedLanguage: transcriptionEngine.detectedLanguage - ) - let diarizedSegments = diarizeResult.segments - - // Log diarization results - let speakers = Set(diarizedSegments.compactMap(\.speaker)) - logger.info("Diarization complete: \(diarizedSegments.count) segments, speakers: \(speakers.sorted()), embeddings: \(diarizeResult.speakerEmbeddings.count)") - - // 4. Create transcript - let transcript = Transcript( - rawText: transcriptionEngine.liveText, - segments: diarizedSegments - ) - logger.info("Transcript created. rawText length: \(transcript.rawText.count), segments: \(transcript.segments.count)") - - // 5. Rebuild timestampedNotes from the final notepad so it faithfully - // mirrors the notes the user actually kept (see TimestampedNote.reconcile). - // Enhancement and markdown export prefer it over the raw text, so it - // must contain every kept line and nothing stale, even after the user - // edits, deletes, or duplicates lines mid-session. - let trimmedNotes = userNotesText.trimmingCharacters(in: .whitespacesAndNewlines) - timestampedNotes = TimestampedNote.reconcile( - notepadText: userNotesText, - log: timestampedNotes, - trailingTimestamp: elapsedTime - ) - - // 6. Save session - let sessionTitle = "Session \(DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .short))" - let meeting = Meeting( - title: sessionTitle, - date: captureManager.recordingStartTime ?? Date(), - duration: duration, - appSource: captureManager.selectedProcess?.name, - appBundleID: captureManager.selectedProcess?.bundleID - ) - meeting.transcript = transcript - transcript.meeting = meeting - - // Save user notes and timestamps - meeting.userNotesText = trimmedNotes.isEmpty ? nil : trimmedNotes - meeting.timestampedNotes = timestampedNotes.isEmpty ? nil : timestampedNotes - - // Save speaker embeddings for lightweight re-clustering (~500KB vs ~115MB raw audio) - meeting.speakerEmbeddings = diarizeResult.speakerEmbeddings - meeting.originalSpeakerCount = speakers.count - meeting.originalSegmentsData = transcript.segmentsData - meeting.detectedLanguage = transcriptionEngine.detectedLanguage - - modelContext.insert(meeting) - try? modelContext.save() - // End-of-recording checkpoint: the session just wrote its transcript, - // segments, and embeddings — make sure those land in the main store file - // immediately, so a later crash or forced reboot can't lose them. - let ok = DatabaseCheckpoint.performCheckpoint(at: AppDelegate.storeURL, mode: .passive) - logger.notice("End-of-recording checkpoint success=\(ok, privacy: .public)") - - if viewWillPersist { - // Select the just-saved meeting so the sidebar reflects what's in - // the detail pane — without this, the previously-selected meeting - // (if any) stays highlighted until the user clicks Done. - selectedMeeting = meeting - - isProcessingNotes = false - savedMeeting = meeting - // Now in the post-recording (saved) state — let ContentView rebuild - // this view fresh on the next "Record" instead of re-showing it. - recordingSaved = true - } - // The pipeline has consumed everything it needs from the engine — - // release it for the next recording (and for deferred model swaps). - transcriptionEngine.endSession() - ownsRecordingSession = false - } + session.startRecording(modelContext: modelContext) } private func initialRegenerateTemplate(for meeting: Meeting) -> NoteTemplate { - meeting.structuredNote?.resolvedTemplate ?? selectedTemplate + meeting.structuredNote?.resolvedTemplate ?? session.selectedTemplate } private func initialRegenerateLanguage(for meeting: Meeting) -> SummaryLanguage { if let note = meeting.structuredNote { return SummaryLanguage.fromStorageCode(note.language) } - return selectedSummaryLanguage + return session.selectedSummaryLanguage } private func detectedSummaryLanguage(for meeting: Meeting) -> SummaryLanguage? { @@ -1182,8 +831,8 @@ struct RecordingView: View { } private func applyEnhancementPreferences(template: NoteTemplate, language: SummaryLanguage, meeting: Meeting) { - selectedTemplate = template - selectedSummaryLanguage = language + session.selectedTemplate = template + session.selectedSummaryLanguage = language if case .custom(let name) = language { summaryLanguageCustomDraft = name } @@ -1196,15 +845,15 @@ struct RecordingView: View { /// Runs note enhancement on the saved meeting using the selected preferences /// from `RegenerateNotesSheet`. private func runEnhancement(template: NoteTemplate, summaryLanguage: SummaryLanguage) { - guard let meeting = savedMeeting, + guard let meeting = session.savedMeeting, let transcript = meeting.transcript, !transcript.rawText.isEmpty, enhancement.isProviderReady else { return } - let currentNotes = userNotesText.trimmingCharacters(in: .whitespacesAndNewlines) + let currentNotes = session.userNotesText.trimmingCharacters(in: .whitespacesAndNewlines) meeting.userNotesText = currentNotes.isEmpty ? nil : currentNotes - // Persist the raw notes as userNotesText (above), but show the model the + // Persist the raw notes as session.userNotesText (above), but show the model the // timestamped form when we have it — it carries when each note was taken. let notesForPrompt: String? let mode: String @@ -1225,7 +874,7 @@ struct RecordingView: View { transcript: transcript.diarizedText, userNotes: notesForPrompt, template: template, - customInstructions: template == .custom ? customInstructions : nil, + customInstructions: template == .custom ? session.customInstructions : nil, summaryLanguage: summaryLanguage, modelContext: modelContext ) diff --git a/Tests/RecordingSessionTests.swift b/Tests/RecordingSessionTests.swift new file mode 100644 index 0000000..14b4083 --- /dev/null +++ b/Tests/RecordingSessionTests.swift @@ -0,0 +1,246 @@ +import AppKit +import SwiftData +import SwiftUI +import XCTest +@testable import Seminarly + +final class RecordingSessionTests: XCTestCase { + @MainActor + private final class TestClock { + var time: TimeInterval = 100 + } + + @MainActor + private func makeSession(clock: TestClock) throws -> (RecordingSession, ModelContainer) { + let config = ModelConfiguration(schema: SeminarlyApp.schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: SeminarlyApp.schema, configurations: [config]) + let capture = AudioCaptureManager() + // Exercise the real asynchronous capture lifecycle without opening any + // audio device, requesting permissions, or downloading speech models. + capture.captureMicrophone = false + let session = RecordingSession( + captureManager: capture, + transcriptionEngine: TranscriptionEngine(), + diarizationEngine: NeuralDiarizationEngine(), + now: { clock.time } + ) + return (session, container) + } + + @MainActor + private func waitForCapture(_ session: RecordingSession) async throws { + for _ in 0..<100 { + if session.isRecording { return } + try await Task.sleep(for: .milliseconds(10)) + } + XCTFail("Capture did not start") + } + + @MainActor + private func makeWindow(session: RecordingSession, container: ModelContainer) -> NSWindow { + let view = RecordingView( + selectedMeeting: .constant(nil), + captureManager: session.captureManager, + session: session, + transcriptionEngine: session.transcriptionEngine, + diarizationEngine: session.diarizationEngine, + audioMonitor: AudioSourceMonitor() + ) + .environment(AppState(recordingSession: session)) + .modelContainer(container) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: view) + return window + } + + @MainActor + func testRecordingSurvivesMinimizeCloseAndReopenWithSameNotesAndTranscript() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + let appState = AppState(recordingSession: session) + session.userNotesText = "Agenda before recording" + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + session.transcriptionEngine.liveText = "Before closing the window" + let id = session.id + + let window = makeWindow(session: session, container: container) + window.makeKeyAndOrderFront(nil) + await Task.yield() + window.miniaturize(nil) + clock.time += 90 + session.updateElapsedTime() + XCTAssertTrue(session.isRecording) + XCTAssertTrue(session.hasBackgroundActivity) + XCTAssertEqual(appState.recordingElapsedTime, 90) + + window.close() + window.contentView = nil + await Task.yield() + clock.time += 60 + session.updateElapsedTime() + XCTAssertTrue(appState.isRecording) + XCTAssertTrue(session.transcriptionEngine.isSessionActive) + XCTAssertNil(session.savedMeeting) + XCTAssertTrue(session.shouldRestoreRecording) + + let reopened = makeWindow(session: appState.recordingSession, container: container) + reopened.makeKeyAndOrderFront(nil) + await Task.yield() + XCTAssertEqual(session.id, id) + XCTAssertEqual(session.elapsedTime, 150) + XCTAssertEqual(session.userNotesText, "Agenda before recording") + XCTAssertEqual(session.transcriptionEngine.liveText, "Before closing the window") + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 0) + reopened.close() + reopened.contentView = nil + + let save = try XCTUnwrap(session.stopRecording()) + XCTAssertNil(session.stopRecording(), "A second window must not start a second save") + await save.value + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(session.savedMeeting?.duration, 150) + XCTAssertEqual(session.savedMeeting?.userNotesText, "Agenda before recording") + XCTAssertEqual(session.savedMeeting?.transcript?.rawText, "Before closing the window") + XCTAssertFalse(session.hasBackgroundActivity) + } + + @MainActor + func testDelayedTimerAndExplicitPauseExcludeOnlyPausedTime() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + clock.time += 127.5 + session.updateElapsedTime() + XCTAssertEqual(session.elapsedTime, 127.5) + + session.pauseRecording() + XCTAssertTrue(session.isPaused) + XCTAssertFalse(session.hasBackgroundActivity) + clock.time += 600 + session.updateElapsedTime() + XCTAssertEqual(session.elapsedTime, 127.5) + + session.resumeRecording() + try await waitForCapture(session) + XCTAssertTrue(session.hasBackgroundActivity) + clock.time += 32.5 + await session.stopRecording()?.value + XCTAssertEqual(session.savedMeeting?.duration, 160) + XCTAssertFalse(session.transcriptionEngine.isSessionActive) + XCTAssertFalse(session.hasBackgroundActivity) + } + + @MainActor + func testTerminationSavesWithoutAnyRecordingView() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.userNotesText = "Background session" + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + clock.time += 20 + session.stopForTermination() + XCTAssertTrue(session.isProcessingNotes) + XCTAssertTrue(session.hasBackgroundActivity) + for _ in 0..<100 { + if !session.ownsRecordingSession { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertFalse(session.ownsRecordingSession) + XCTAssertFalse(session.hasBackgroundActivity) + XCTAssertEqual(session.savedMeeting?.duration, 20) + XCTAssertEqual(session.savedMeeting?.userNotesText, "Background session") + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + } + + @MainActor + func testStopDuringStartupCannotResurrectCaptureOrLeaveAnActivity() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.startRecording(modelContext: container.mainContext) + session.stopForTermination() + // The detached Core Audio task must observe its invalidated generation. + try await Task.sleep(for: .milliseconds(100)) + XCTAssertFalse(session.isActive) + XCTAssertFalse(session.isRecording) + XCTAssertFalse(session.transcriptionEngine.isSessionActive) + XCTAssertFalse(session.hasBackgroundActivity) + XCTAssertNil(session.savedMeeting) + } + + @MainActor + func testCaptureStartFailureReleasesSessionWithoutAViewAndCanRetry() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.startRecording(modelContext: container.mainContext) + session.captureManager.state = .error("Test startup failure") + XCTAssertFalse(session.ownsRecordingSession) + XCTAssertFalse(session.hasBackgroundActivity) + XCTAssertFalse(session.transcriptionEngine.isSessionActive) + XCTAssertEqual(session.captureManager.state, .error("Test startup failure")) + + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + XCTAssertTrue(session.hasBackgroundActivity) + await session.stopRecording()?.value + XCTAssertNotNil(session.savedMeeting) + } + + @MainActor + func testCaptureFailureAfterStartupSavesOnceWithoutAView() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + session.transcriptionEngine.liveText = "Audio before device failure" + clock.time += 45 + session.captureManager.state = .error("Test device failure") + XCTAssertTrue(session.isProcessingNotes) + XCTAssertTrue(session.hasBackgroundActivity) + for _ in 0..<100 { + if !session.ownsRecordingSession { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(session.savedMeeting?.duration, 45) + XCTAssertEqual(session.savedMeeting?.transcript?.rawText, "Audio before device failure") + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + XCTAssertFalse(session.hasBackgroundActivity) + } + + @MainActor + func testNewRecordingCannotResetAnActiveOrSavingSession() async throws { + let clock = TestClock() + let (session, container) = try makeSession(clock: clock) + session.userNotesText = "Keep these notes" + session.startRecording(modelContext: container.mainContext) + try await waitForCapture(session) + let id = session.id + session.prepareNewRecording() + session.startRecording(modelContext: container.mainContext) + XCTAssertEqual(session.id, id) + XCTAssertEqual(session.userNotesText, "Keep these notes") + let save = try XCTUnwrap(session.stopRecording()) + session.prepareNewRecording() + session.startRecording(modelContext: container.mainContext) + XCTAssertEqual(session.id, id) + await save.value + + session.prepareNewRecording() + XCTAssertNotEqual(session.id, id) + XCTAssertTrue(session.userNotesText.isEmpty) + XCTAssertNil(session.savedMeeting) + XCTAssertTrue(session.isInSetupPhase) + } + + @MainActor + func testClosingLastWindowDoesNotQuitApp() { + XCTAssertFalse(AppDelegate().applicationShouldTerminateAfterLastWindowClosed(.shared)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 546b986..5e6d910 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,6 +8,13 @@ Seminarly is a native macOS app built with Swift 6 and SwiftUI. It follows a pip ### Audio (`Sources/Audio/`) +- `RecordingSession` + - App-owned `@MainActor` observable session, shared by all windows and the menu bar + - Owns capture, elapsed time, user notes, preferences, and the finalize/diarize/save task + - Closing or minimizing a window does not stop recording; reopening reconnects to the same session + - Holds a `ProcessInfo` user-initiated activity during capture and saving to prevent App Nap and idle system sleep, while allowing the display to sleep; releases it on an explicit pause, startup failure, or completed save + - Uses a monotonic clock for duration and a timer in common run-loop modes for display updates + - `AppDelegate` stops and saves this session directly on Quit, even with no windows - `CoreAudioUtils` - Static helpers wrapping `AudioObjectGetPropertyData` - `getProcessList()` — queries `kAudioHardwarePropertyProcessObjectList` for all audio-registered processes diff --git a/docs/TESTING.md b/docs/TESTING.md index e8481c5..e1b4ec2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -12,6 +12,7 @@ |------|---------------| | `AudioBufferAccumulatorTests.swift` | Thread-safe buffer, format conversion | | `AudioFormatConverterTests.swift` | 48kHz/44.1kHz → 16kHz mono conversion | +| `RecordingSessionTests.swift` | Window-independent capture, close/reopen/minimize, elapsed time, explicit pause, startup/error cleanup, and saving without a view | | `ClaudeAPIClientTests.swift` | Keychain save/load/delete | | `CodableModelTests.swift` | Codable round-trips for storage models | | `DiarizationEngineTests.swift` | MFCC-based speaker assignment | @@ -116,6 +117,13 @@ ### Manual Tests **Audio Capture** +- [ ] While recording, close the last window (red close button / Cmd+W), wait at least 2 minutes, then reopen from the menu bar or Dock — the same session, notes, timer, and transcript continue +- [ ] While recording, minimize the window (yellow button / Cmd+M) for at least 2 minutes — audio and transcription continue, and the timer reflects the full elapsed time on return +- [ ] With no open window, use menu-bar Pause / Resume / Stop — only these explicit controls change capture state, and Stop saves one session +- [ ] With no open window, Quit the app — wait for transcription and saving to finish, then verify the saved session after relaunch +- [ ] Pause explicitly, then close/reopen — the session stays paused and its duration excludes paused time +- [ ] Close the window during capture startup and during finalization — startup continues; finalization still saves exactly once +- [ ] In Activity Monitor, enable the App Nap column: active recording must remain out of App Nap while hidden; after pause/stop the recording activity assertion is released - [ ] Select Zoom from process list → record 30s → audio is clean (no distortion) - [ ] Select Google Meet → record → audio captures remote participants - [ ] Mic-only mode (no system audio) → captures user voice diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 0000000..4ba7671 --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +case "$MODE" in + run|--build-only|--debug|--logs|--telemetry|--verify) ;; + *) echo "Usage: $0 [--build-only|--debug|--logs|--telemetry|--verify]" >&2; exit 2 ;; +esac + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DERIVED_DATA="${SEMINARLY_DERIVED_DATA:-$PROJECT_ROOT/.build/DerivedData}" +APP_BUNDLE="$DERIVED_DATA/Build/Products/Debug/Seminarly.app" +cd "$PROJECT_ROOT" + +if [[ "$MODE" != "--build-only" ]]; then + # Use normal application termination so an active recording can finish saving. + # Never SIGKILL a session merely to relaunch a development build. + swift - <<'SWIFT' +import AppKit +let apps = NSRunningApplication.runningApplications(withBundleIdentifier: "ai.seminarly.Seminarly") +for app in apps { app.terminate() } +let deadline = Date().addingTimeInterval(120) +while apps.contains(where: { !$0.isTerminated }) && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) +} +if apps.contains(where: { !$0.isTerminated }) { + fputs("Seminarly is still saving or has declined to quit. Relaunch cancelled.\n", stderr) + exit(1) +} +SWIFT +fi + +xcodegen generate +xcodebuild -project Seminarly.xcodeproj -scheme Seminarly \ + -destination 'platform=macOS' -derivedDataPath "$DERIVED_DATA" \ + -clonedSourcePackagesDirPath "$PROJECT_ROOT/.build/DerivedData/SourcePackages" \ + -disableAutomaticPackageResolution -skipPackageUpdates build + +[[ "$MODE" == "--build-only" ]] && exit 0 + +if [[ "$MODE" == "--debug" ]]; then + exec lldb -- "$APP_BUNDLE/Contents/MacOS/Seminarly" +fi + +/usr/bin/open -n "$APP_BUNDLE" +case "$MODE" in + --logs) + exec /usr/bin/log stream --info --style compact --predicate 'process == "Seminarly"' + ;; + --telemetry) + exec /usr/bin/log stream --info --style compact --predicate 'subsystem == "ai.seminarly.Seminarly"' + ;; + --verify) + sleep 2 + pgrep -f "$APP_BUNDLE/Contents/MacOS/Seminarly" >/dev/null + ;; +esac