diff --git a/VoiceInk/Whisper/WhisperModelMigration.swift b/VoiceInk/Whisper/WhisperModelMigration.swift new file mode 100644 index 0000000..7f31d03 --- /dev/null +++ b/VoiceInk/Whisper/WhisperModelMigration.swift @@ -0,0 +1,131 @@ +import Foundation +import os + +/// Handles migration of Whisper models from Documents folder to Application Support folder +class WhisperModelMigration { + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "WhisperModelMigration") + + /// Source directory in Documents folder + private let sourceDirectory: URL + + /// Destination directory in Application Support folder + private let destinationDirectory: URL + + /// Flag to track if migration has been completed + private let migrationCompletedKey = "WhisperModelMigrationCompleted" + + /// Initializes a new migration handler + init() { + // Define source directory (old location in Documents) + self.sourceDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("WhisperModels") + + // Define destination directory (new location in Application Support) + self.destinationDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("com.prakashjoshipax.VoiceInk") + .appendingPathComponent("WhisperModels") + } + + /// Checks if migration is needed + var isMigrationNeeded: Bool { + // If migration was already completed, no need to check further + if UserDefaults.standard.bool(forKey: migrationCompletedKey) { + return false + } + + // Check if source directory exists and has content + if FileManager.default.fileExists(atPath: sourceDirectory.path) { + do { + let contents = try FileManager.default.contentsOfDirectory(at: sourceDirectory, includingPropertiesForKeys: nil) + // Only migrate if there are .bin files in the source directory + return contents.contains { $0.pathExtension == "bin" } + } catch { + logger.error("Error checking source directory: \(error.localizedDescription)") + } + } + + return false + } + + /// Creates the destination directory if needed + private func createDestinationDirectoryIfNeeded() -> Bool { + do { + if !FileManager.default.fileExists(atPath: destinationDirectory.path) { + try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + logger.info("Created destination directory at \(self.destinationDirectory.path)") + } + return true + } catch { + logger.error("Failed to create destination directory: \(error.localizedDescription)") + return false + } + } + + /// Performs the migration of models from Documents to Application Support + /// - Returns: A tuple containing success status and an array of migrated model URLs + func migrateModels() async -> (success: Bool, migratedModels: [URL]) { + guard isMigrationNeeded else { + logger.info("Migration not needed or already completed") + return (true, []) + } + + guard createDestinationDirectoryIfNeeded() else { + return (false, []) + } + + var migratedModels: [URL] = [] + + do { + // Get all .bin files from source directory + let modelFiles = try FileManager.default.contentsOfDirectory(at: sourceDirectory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "bin" } + + if modelFiles.isEmpty { + logger.info("No model files found to migrate") + markMigrationAsCompleted() + return (true, []) + } + + logger.info("Found \(modelFiles.count) model files to migrate") + + // Copy each model file to the new location + for sourceURL in modelFiles { + let fileName = sourceURL.lastPathComponent + let destinationURL = destinationDirectory.appendingPathComponent(fileName) + + // Skip if file already exists at destination + if FileManager.default.fileExists(atPath: destinationURL.path) { + logger.info("Model already exists at destination: \(fileName)") + migratedModels.append(destinationURL) + continue + } + + do { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + logger.info("Successfully migrated model: \(fileName)") + migratedModels.append(destinationURL) + } catch { + logger.error("Failed to copy model \(fileName): \(error.localizedDescription)") + } + } + + // Mark migration as completed if at least some models were migrated + if !migratedModels.isEmpty { + markMigrationAsCompleted() + return (true, migratedModels) + } else { + return (false, []) + } + + } catch { + logger.error("Error during migration: \(error.localizedDescription)") + return (false, []) + } + } + + /// Marks the migration as completed in UserDefaults + private func markMigrationAsCompleted() { + UserDefaults.standard.set(true, forKey: migrationCompletedKey) + logger.info("Migration marked as completed") + } +} \ No newline at end of file diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 43f4d49..42444b4 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -66,10 +66,13 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { private var transcriptionStartTime: Date? private var notchWindowManager: NotchWindowManager? private var miniWindowManager: MiniWindowManager? + private let modelMigration = WhisperModelMigration() init(modelContext: ModelContext, enhancementService: AIEnhancementService? = nil) { self.modelContext = modelContext - self.modelsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("WhisperModels") + self.modelsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("com.prakashjoshipax.VoiceInk") + .appendingPathComponent("WhisperModels") self.recordingsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("com.prakashjoshipax.VoiceInk") .appendingPathComponent("Recordings") @@ -87,6 +90,30 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let savedModel = availableModels.first(where: { $0.name == savedModelName }) { currentModel = savedModel } + + Task { + await migrateModelsIfNeeded() + } + } + + private func migrateModelsIfNeeded() async { + if modelMigration.isMigrationNeeded { + logger.info("Starting model migration from Documents to Application Support") + let (success, migratedModels) = await modelMigration.migrateModels() + + if success && !migratedModels.isEmpty { + logger.info("Successfully migrated \(migratedModels.count) models") + await MainActor.run { + loadAvailableModels() + + if currentModel == nil, let firstModel = availableModels.first { + Task { + await setDefaultModel(firstModel) + } + } + } + } + } } private func createModelsDirectoryIfNeeded() { @@ -172,7 +199,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { await ActiveWindowService.shared.applyConfigurationForCurrentApp() - // Trigger screen capture if enhancement and screen capture are enabled if let enhancementService = self.enhancementService, enhancementService.isEnhancementEnabled && enhancementService.useScreenCaptureContext { @@ -473,17 +499,13 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { @objc public func handleToggleMiniRecorder() { if isMiniRecorderVisible { - // If the recorder is visible, toggle recording Task { await toggleRecord() } } else { - // Start recording first, then show UI Task { - // Start recording immediately await toggleRecord() - // Play sound and show UI after recording has started SoundManager.shared.playStartSound() await MainActor.run { @@ -524,15 +546,15 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { if isMiniRecorderVisible { await dismissMiniRecorder() } else { - // Start recording first - await toggleRecord() - - // Play sound and show UI after recording has started - SoundManager.shared.playStartSound() - - await MainActor.run { - showRecorderPanel() - isMiniRecorderVisible = true + Task { + await toggleRecord() + + SoundManager.shared.playStartSound() + + await MainActor.run { + showRecorderPanel() + isMiniRecorderVisible = true + } } } }