From 2970895376ee9bbee2db0fbc24ac44e9f32f5a35 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Thu, 7 Aug 2025 01:18:35 +0545 Subject: [PATCH] Support for zero data retention on --- VoiceInk/Notifications/AppNotifications.swift | 1 + .../AudioFileTranscriptionManager.swift | 3 + .../AudioFileTranscriptionService.swift | 2 + VoiceInk/Services/ImportExportService.swift | 6 ++ .../TranscriptionAutoCleanupService.swift | 69 +++++++++++++++++++ .../Settings/AudioCleanupSettingsView.swift | 38 +++++++++- VoiceInk/Views/Settings/SettingsView.swift | 8 +-- VoiceInk/VoiceInk.swift | 9 +++ VoiceInk/Whisper/WhisperState.swift | 4 ++ 9 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 VoiceInk/Services/TranscriptionAutoCleanupService.swift diff --git a/VoiceInk/Notifications/AppNotifications.swift b/VoiceInk/Notifications/AppNotifications.swift index cebd67b..ea37038 100644 --- a/VoiceInk/Notifications/AppNotifications.swift +++ b/VoiceInk/Notifications/AppNotifications.swift @@ -11,4 +11,5 @@ extension Notification.Name { static let navigateToDestination = Notification.Name("navigateToDestination") static let promptSelectionChanged = Notification.Name("promptSelectionChanged") static let powerModeConfigurationApplied = Notification.Name("powerModeConfigurationApplied") + static let transcriptionCreated = Notification.Name("transcriptionCreated") } diff --git a/VoiceInk/Services/AudioFileTranscriptionManager.swift b/VoiceInk/Services/AudioFileTranscriptionManager.swift index 560dccf..28e8b97 100644 --- a/VoiceInk/Services/AudioFileTranscriptionManager.swift +++ b/VoiceInk/Services/AudioFileTranscriptionManager.swift @@ -137,6 +137,7 @@ class AudioTranscriptionManager: ObservableObject { ) modelContext.insert(transcription) try modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: transcription) currentTranscription = transcription } catch { logger.error("Enhancement failed: \(error.localizedDescription)") @@ -149,6 +150,7 @@ class AudioTranscriptionManager: ObservableObject { ) modelContext.insert(transcription) try modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: transcription) currentTranscription = transcription } } else { @@ -161,6 +163,7 @@ class AudioTranscriptionManager: ObservableObject { ) modelContext.insert(transcription) try modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: transcription) currentTranscription = transcription } diff --git a/VoiceInk/Services/AudioFileTranscriptionService.swift b/VoiceInk/Services/AudioFileTranscriptionService.swift index 40420ce..f19a5de 100644 --- a/VoiceInk/Services/AudioFileTranscriptionService.swift +++ b/VoiceInk/Services/AudioFileTranscriptionService.swift @@ -110,6 +110,7 @@ class AudioTranscriptionService: ObservableObject { modelContext.insert(newTranscription) do { try modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription) } catch { logger.error("❌ Failed to save transcription: \(error.localizedDescription)") } @@ -130,6 +131,7 @@ class AudioTranscriptionService: ObservableObject { modelContext.insert(newTranscription) do { try modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription) } catch { logger.error("❌ Failed to save transcription: \(error.localizedDescription)") } diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 39c7862..7a9a253 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -13,6 +13,7 @@ struct GeneralSettings: Codable { let isMenuBarOnly: Bool? let useAppleScriptPaste: Bool? let recorderType: String? + let doNotMaintainTranscriptHistory: Bool? let isAudioCleanupEnabled: Bool? let audioRetentionPeriod: Int? @@ -43,6 +44,7 @@ class ImportExportService { private let keyIsMenuBarOnly = "IsMenuBarOnly" private let keyUseAppleScriptPaste = "UseAppleScriptPaste" private let keyRecorderType = "RecorderType" + private let keyDoNotMaintainTranscriptHistory = "DoNotMaintainTranscriptHistory" private let keyIsAudioCleanupEnabled = "IsAudioCleanupEnabled" private let keyAudioRetentionPeriod = "AudioRetentionPeriod" @@ -87,6 +89,7 @@ class ImportExportService { isMenuBarOnly: menuBarManager.isMenuBarOnly, useAppleScriptPaste: UserDefaults.standard.bool(forKey: keyUseAppleScriptPaste), recorderType: whisperState.recorderType, + doNotMaintainTranscriptHistory: UserDefaults.standard.bool(forKey: keyDoNotMaintainTranscriptHistory), isAudioCleanupEnabled: UserDefaults.standard.bool(forKey: keyIsAudioCleanupEnabled), audioRetentionPeriod: UserDefaults.standard.integer(forKey: keyAudioRetentionPeriod), @@ -230,6 +233,9 @@ class ImportExportService { if let recType = general.recorderType { whisperState.recorderType = recType } + if let doNotMaintainHistory = general.doNotMaintainTranscriptHistory { + UserDefaults.standard.set(doNotMaintainHistory, forKey: self.keyDoNotMaintainTranscriptHistory) + } if let audioCleanup = general.isAudioCleanupEnabled { UserDefaults.standard.set(audioCleanup, forKey: self.keyIsAudioCleanupEnabled) } diff --git a/VoiceInk/Services/TranscriptionAutoCleanupService.swift b/VoiceInk/Services/TranscriptionAutoCleanupService.swift new file mode 100644 index 0000000..f2217c4 --- /dev/null +++ b/VoiceInk/Services/TranscriptionAutoCleanupService.swift @@ -0,0 +1,69 @@ +import Foundation +import SwiftData +import OSLog + +/// A service that automatically deletes transcriptions when "Do Not Maintain Transcript History" is enabled +class TranscriptionAutoCleanupService { + static let shared = TranscriptionAutoCleanupService() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "TranscriptionAutoCleanupService") + private var modelContext: ModelContext? + + private init() {} + + /// Start monitoring for new transcriptions and auto-delete if needed + func startMonitoring(modelContext: ModelContext) { + self.modelContext = modelContext + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTranscriptionCreated(_:)), + name: .transcriptionCreated, + object: nil + ) + + logger.info("TranscriptionAutoCleanupService started monitoring") + } + + /// Stop monitoring for transcriptions + func stopMonitoring() { + NotificationCenter.default.removeObserver(self, name: .transcriptionCreated, object: nil) + logger.info("TranscriptionAutoCleanupService stopped monitoring") + } + + @objc private func handleTranscriptionCreated(_ notification: Notification) { + // Check if no-retention mode is enabled + guard UserDefaults.standard.bool(forKey: "DoNotMaintainTranscriptHistory") else { + return + } + + guard let transcription = notification.object as? Transcription, + let modelContext = self.modelContext else { + logger.error("Invalid transcription or missing model context") + return + } + + logger.info("Auto-deleting transcription for zero data retention") + + // Delete the audio file if it exists + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString) { + do { + try FileManager.default.removeItem(at: url) + logger.debug("Deleted audio file: \(url.lastPathComponent)") + } catch { + logger.error("Failed to delete audio file: \(error.localizedDescription)") + } + } + + // Delete the transcription from the database + modelContext.delete(transcription) + + do { + try modelContext.save() + logger.debug("Successfully deleted transcription from database") + } catch { + logger.error("Failed to save after transcription deletion: \(error.localizedDescription)") + } + } +} \ No newline at end of file diff --git a/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift index 9e73219..db513c2 100644 --- a/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift +++ b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift @@ -6,6 +6,7 @@ struct AudioCleanupSettingsView: View { @EnvironmentObject private var whisperState: WhisperState // Audio cleanup settings + @AppStorage("DoNotMaintainTranscriptHistory") private var doNotMaintainTranscriptHistory = false @AppStorage("IsAudioCleanupEnabled") private var isAudioCleanupEnabled = true @AppStorage("AudioRetentionPeriod") private var audioRetentionPeriod = 7 @State private var isPerformingCleanup = false @@ -16,16 +17,47 @@ struct AudioCleanupSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("VoiceInk can automatically delete audio files from transcription history while preserving the text transcripts.") + Text("Control how VoiceInk handles your transcription data and audio recordings for privacy and storage management.") .font(.system(size: 13)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - Toggle("Enable automatic audio cleanup", isOn: $isAudioCleanupEnabled) + Text("Data Retention") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + .padding(.top, 8) + + Toggle("Do not maintain transcript history", isOn: $doNotMaintainTranscriptHistory) .toggleStyle(.switch) .padding(.vertical, 4) - if isAudioCleanupEnabled { + if doNotMaintainTranscriptHistory { + Text("When enabled, no transcription history will be saved. This provides zero data retention for maximum privacy.") + .font(.system(size: 13)) + .foregroundColor(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + + if !doNotMaintainTranscriptHistory { + Divider() + .padding(.vertical, 8) + + Text("Audio File Management") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Text("Automatically delete audio files from transcription history while preserving the text transcripts.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Toggle("Enable automatic audio cleanup", isOn: $isAudioCleanupEnabled) + .toggleStyle(.switch) + .padding(.vertical, 4) + } + + if isAudioCleanupEnabled && !doNotMaintainTranscriptHistory { VStack(alignment: .leading, spacing: 8) { Text("Retention Period") .font(.system(size: 14, weight: .medium)) diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index 9485fae..ce706e1 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -193,11 +193,11 @@ struct SettingsView: View { } } - // Audio Cleanup Section + // Data & Privacy Section SettingsSection( - icon: "trash.circle", - title: "Audio Cleanup", - subtitle: "Manage recording storage" + icon: "lock.shield", + title: "Data & Privacy", + subtitle: "Control transcript history and storage" ) { AudioCleanupSettingsView() } diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index a5b36de..54306bf 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -21,6 +21,9 @@ struct VoiceInkApp: App { // Audio cleanup manager for automatic deletion of old audio files private let audioCleanupManager = AudioCleanupManager.shared + // Transcription auto-cleanup service for zero data retention + private let transcriptionAutoCleanupService = TranscriptionAutoCleanupService.shared + init() { do { let schema = Schema([ @@ -98,6 +101,9 @@ struct VoiceInkApp: App { // Start the automatic audio cleanup process audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext) + + // Start the transcription auto-cleanup service for zero data retention + transcriptionAutoCleanupService.startMonitoring(modelContext: container.mainContext) } .background(WindowAccessor { window in WindowManager.shared.configureWindow(window) @@ -107,6 +113,9 @@ struct VoiceInkApp: App { // Stop the automatic audio cleanup process audioCleanupManager.stopAutomaticCleanup() + + // Stop the transcription auto-cleanup service + transcriptionAutoCleanupService.stopMonitoring() } } else { OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding) diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 5aa9395..622960e 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -311,6 +311,7 @@ class WhisperState: NSObject, ObservableObject { ) modelContext.insert(newTranscription) try? modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription) text = enhancedText } catch { let newTranscription = Transcription( @@ -323,6 +324,7 @@ class WhisperState: NSObject, ObservableObject { ) modelContext.insert(newTranscription) try? modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription) await MainActor.run { NotificationManager.shared.showNotification( @@ -341,6 +343,7 @@ class WhisperState: NSObject, ObservableObject { ) modelContext.insert(newTranscription) try? modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: newTranscription) } if case .trialExpired = licenseViewModel.licenseState { @@ -395,6 +398,7 @@ class WhisperState: NSObject, ObservableObject { modelContext.insert(failedTranscription) try? modelContext.save() + NotificationCenter.default.post(name: .transcriptionCreated, object: failedTranscription) } } catch { logger.error("❌ Could not create a record for the failed transcription: \(error.localizedDescription)")