feat: retain meeting audio for three days

This commit is contained in:
2026-07-22 16:24:08 +02:00
parent 94d8469eba
commit f1d57da227
7 changed files with 120 additions and 43 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.13;
MARKETING_VERSION = 1.1.14;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.13;
MARKETING_VERSION = 1.1.14;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+13 -33
View File
@@ -35,6 +35,7 @@ final class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine()
private var sessionID = UUID()
private var meetingID = UUID()
private var processTap: ProcessTap?
private let permission = AudioRecordingPermission()
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
@@ -59,11 +60,12 @@ final class AudioManager: NSObject, ObservableObject {
NotificationCenter.default.removeObserver(self)
}
func startRecording() {
func startRecording(for meetingID: UUID) {
errorMessage = nil
lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true)
sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date()
do {
try prepareAudioFiles()
@@ -76,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
}
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID
let completedMeetingID = meetingID
let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles()
isProcessing = true
@@ -97,16 +99,15 @@ final class AudioManager: NSObject, ObservableObject {
)
transcriptChunks = updated
let completedFiles = files.compactMap { $0 }
if failures.isEmpty {
lastRecoveryAudioFolderName = nil
removeAudioFiles(completedFiles)
} else {
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent
let recoveryMessage = recoveryFolder == nil
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
if !failures.isEmpty {
let recoveryMessage = audioFolder == nil
? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)."
: " Audio was kept for three days. Use Show Audio Folder in the Meetingnotes menu to find it."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
} else if audioFolder == nil, !completedFiles.isEmpty {
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its three-day storage folder."
}
return updated
}
@@ -502,29 +503,8 @@ final class AudioManager: NSObject, ObservableObject {
for url in urls { try? FileManager.default.removeItem(at: url) }
}
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? {
guard !urls.isEmpty,
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
do {
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
}
private func resetAudioLevels() {
@@ -2,6 +2,7 @@
// Handles local storage of meetings and app data
import Foundation
import AppKit
/// Manages local file storage for meetings and app data
class LocalStorageManager {
@@ -16,6 +17,7 @@ class LocalStorageManager {
private let meetingsDirectory: URL
private let templatesDirectory: URL
private let recoveryDirectory: URL
private let audioRetentionInterval: TimeInterval = 3 * 24 * 60 * 60
private init() {
// Get the app's documents directory
@@ -28,7 +30,7 @@ class LocalStorageManager {
// Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory,
@@ -37,6 +39,8 @@ class LocalStorageManager {
withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
}
// MARK: - Meeting Management
@@ -151,6 +155,66 @@ class LocalStorageManager {
// MARK: - Recovery Audio
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
guard !urls.isEmpty else { return nil }
purgeExpiredAudioFolders()
let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
let destination = folder.appendingPathComponent(url.lastPathComponent)
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: url, to: destination)
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
func purgeExpiredAudioFolders(now: Date = Date()) {
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
let expirationDate = now.addingTimeInterval(-audioRetentionInterval)
for folder in folders {
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
let audioFiles = recoveryAudioFiles(in: folder)
let newestDate = audioFiles.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey])
return values?.contentModificationDate ?? values?.creationDate
}.max()
guard let newestDate else {
try? FileManager.default.removeItem(at: folder)
continue
}
if newestDate < expirationDate {
try? FileManager.default.removeItem(at: folder)
}
}
}
@MainActor
func showAudioFolderInFinder(_ folder: URL? = nil) {
let target = folder ?? recoveryDirectory
try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
NSWorkspace.shared.open(target)
}
func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty,
@@ -245,6 +309,22 @@ class LocalStorageManager {
try? FileManager.default.removeItem(at: folder)
}
private func migrateLegacyRecoveryAudio() {
let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: legacyDirectory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else { return }
for folder in folders {
let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
try? FileManager.default.moveItem(at: folder, to: destination)
}
try? FileManager.default.removeItem(at: legacyDirectory)
}
/// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource()
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
activeMeetingId = meetingId
recordingStartedAt = Date()
audioManager.startRecording()
audioManager.startRecording(for: meetingId)
}
func stopRecording() async -> [TranscriptChunk] {
+10
View File
@@ -46,6 +46,10 @@ struct MeetingnotesApp: App {
.commands {
CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater)
Divider()
Button("Show Audio Folder") {
LocalStorageManager.shared.showAudioFolderInFinder()
}
}
}
@@ -79,6 +83,12 @@ private struct MeetingnotesMenu: View {
Label("Open Meetingnotes", systemImage: "macwindow")
}
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider()
Button {
@@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject {
throw CocoaError(.fileWriteUnknown)
}
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes()
} catch {
errorMessage = error.localizedDescription
@@ -262,6 +258,11 @@ class MeetingViewModel: ObservableObject {
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
}
}
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates()
+6
View File
@@ -267,6 +267,12 @@ struct MeetingDetailContentView: View {
}
.disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider()
}