diff --git a/VoiceInk/AppDelegate.swift b/VoiceInk/AppDelegate.swift index d734069..bf27f48 100644 --- a/VoiceInk/AppDelegate.swift +++ b/VoiceInk/AppDelegate.swift @@ -4,6 +4,7 @@ import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { updateActivationPolicy() + cleanupLegacyUserDefaults() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -42,4 +43,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApp.windows.first?.makeKeyAndOrderFront(nil) } } + + private func cleanupLegacyUserDefaults() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "defaultPowerModeConfigV2") + defaults.removeObject(forKey: "isPowerModeEnabled") + } } diff --git a/VoiceInk/MiniRecorderShortcutManager.swift b/VoiceInk/MiniRecorderShortcutManager.swift index 1bb3588..b329b64 100644 --- a/VoiceInk/MiniRecorderShortcutManager.swift +++ b/VoiceInk/MiniRecorderShortcutManager.swift @@ -180,7 +180,7 @@ class MiniRecorderShortcutManager: ObservableObject { if index < availableConfigurations.count { let selectedConfig = availableConfigurations[index] powerModeManager.setActiveConfiguration(selectedConfig) - await ActiveWindowService.shared.applyConfiguration(selectedConfig) + await PowerModeSessionManager.shared.beginSession(with: selectedConfig) } } else { guard let enhancementService = await self.whisperState.getEnhancementService() else { return } diff --git a/VoiceInk/PowerMode/ActiveWindowService.swift b/VoiceInk/PowerMode/ActiveWindowService.swift index 6eebe7c..e5038d4 100644 --- a/VoiceInk/PowerMode/ActiveWindowService.swift +++ b/VoiceInk/PowerMode/ActiveWindowService.swift @@ -26,132 +26,42 @@ class ActiveWindowService: ObservableObject { func applyConfigurationForCurrentApp() async { guard let frontmostApp = NSWorkspace.shared.frontmostApplication, - let bundleIdentifier = frontmostApp.bundleIdentifier else { return } - + let bundleIdentifier = frontmostApp.bundleIdentifier else { + await PowerModeSessionManager.shared.endSession() + return + } + await MainActor.run { currentApplication = frontmostApp } - + + var configToApply: PowerModeConfig? + if let browserType = BrowserType.allCases.first(where: { $0.bundleIdentifier == bundleIdentifier }) { - logger.debug("🌐 Detected Browser: \(browserType.displayName)") - do { - logger.debug("📝 Attempting to get URL from \(browserType.displayName)") let currentURL = try await browserURLService.getCurrentURL(from: browserType) - logger.debug("📍 Successfully got URL: \(currentURL)") - if let config = PowerModeManager.shared.getConfigurationForURL(currentURL) { - logger.debug("⚙️ Found URL Configuration: \(config.name) for URL: \(currentURL)") - await MainActor.run { - PowerModeManager.shared.setActiveConfiguration(config) - } - await applyConfiguration(config) - return - } else { - logger.debug("📝 No URL configuration found for: \(currentURL)") + configToApply = config } } catch { logger.error("❌ Failed to get URL from \(browserType.displayName): \(error.localizedDescription)") } } - - if let config = PowerModeManager.shared.getConfigurationForApp(bundleIdentifier) { + + if configToApply == nil { + configToApply = PowerModeManager.shared.getConfigurationForApp(bundleIdentifier) + } + + if let config = configToApply { await MainActor.run { PowerModeManager.shared.setActiveConfiguration(config) } - await applyConfiguration(config) + await PowerModeSessionManager.shared.beginSession(with: config) } else { await MainActor.run { PowerModeManager.shared.setActiveConfiguration(nil) } - } - } - - /// Applies a specific configuration - func applyConfiguration(_ config: PowerModeConfig) async { - guard let enhancementService = enhancementService else { return } - - // Capture current state before making changes - let wasScreenCaptureEnabled = await MainActor.run { - enhancementService.useScreenCaptureContext - } - let wasEnhancementEnabled = await MainActor.run { - enhancementService.isEnhancementEnabled - } - - await MainActor.run { - enhancementService.isEnhancementEnabled = config.isAIEnhancementEnabled - enhancementService.useScreenCaptureContext = config.useScreenCapture - - if config.isAIEnhancementEnabled { - if let promptId = config.selectedPrompt, - let uuid = UUID(uuidString: promptId) { - enhancementService.selectedPromptId = uuid - } else { - if let firstPrompt = enhancementService.allPrompts.first { - enhancementService.selectedPromptId = firstPrompt.id - } - } - } - - if config.isAIEnhancementEnabled, - let aiService = enhancementService.getAIService() { - - if let providerName = config.selectedAIProvider, - let provider = AIProvider(rawValue: providerName) { - aiService.selectedProvider = provider - - if let model = config.selectedAIModel, - !model.isEmpty { - aiService.selectModel(model) - } - } - } - - if let language = config.selectedLanguage { - UserDefaults.standard.set(language, forKey: "SelectedLanguage") - NotificationCenter.default.post(name: .languageDidChange, object: nil) - } - } - - if let whisperState = self.whisperState, - let modelName = config.selectedTranscriptionModelName, - let selectedModel = await whisperState.allAvailableModels.first(where: { $0.name == modelName }) { - - let currentModelName = await MainActor.run { whisperState.currentTranscriptionModel?.name } - - // Only change the model if it's different from the current one. - if currentModelName != modelName { - // Set the new model as default. This works for both local and cloud models. - await whisperState.setDefaultTranscriptionModel(selectedModel) - - switch selectedModel.provider { - case .local: - await whisperState.cleanupModelResources() - - if let localModel = await whisperState.availableModels.first(where: { $0.name == selectedModel.name }) { - do { - try await whisperState.loadModel(localModel) - } catch { - logger.error("❌ Power Mode: Failed to load local model '\(localModel.name)': \(error.localizedDescription)") - } - } - - case .parakeet: - await whisperState.cleanupModelResources() - - default: - await whisperState.cleanupModelResources() - } - } - } - - // Wait for UI changes and model loading to complete first - try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds - - // Then check if we should capture - if config.isAIEnhancementEnabled && config.useScreenCapture { - await enhancementService.captureScreenContext() + await PowerModeSessionManager.shared.endSession() } } } diff --git a/VoiceInk/PowerMode/PowerModePopover.swift b/VoiceInk/PowerMode/PowerModePopover.swift index c2d211d..06a5c6f 100644 --- a/VoiceInk/PowerMode/PowerModePopover.swift +++ b/VoiceInk/PowerMode/PowerModePopover.swift @@ -72,7 +72,7 @@ struct PowerModePopover: View { private func applySelectedConfiguration() { Task { if let config = selectedConfig { - await ActiveWindowService.shared.applyConfiguration(config) + await PowerModeSessionManager.shared.beginSession(with: config) } } } diff --git a/VoiceInk/PowerMode/PowerModeSessionManager.swift b/VoiceInk/PowerMode/PowerModeSessionManager.swift new file mode 100644 index 0000000..debe5f7 --- /dev/null +++ b/VoiceInk/PowerMode/PowerModeSessionManager.swift @@ -0,0 +1,210 @@ +import Foundation +import AppKit + +// Represents the state of the application that can be modified by a Power Mode. +// This struct captures the settings that will be temporarily overridden. +struct ApplicationState: Codable { + var isEnhancementEnabled: Bool + var useScreenCaptureContext: Bool + var selectedPromptId: String? // Storing as String for Codable simplicity + var selectedAIProvider: String? + var selectedAIModel: String? + var selectedLanguage: String? + var transcriptionModelName: String? +} + +// Represents an active Power Mode session. +struct PowerModeSession: Codable { + let id: UUID + let startTime: Date + var originalState: ApplicationState +} + +@MainActor +class PowerModeSessionManager { + static let shared = PowerModeSessionManager() + private let sessionKey = "powerModeActiveSession.v1" + + private var whisperState: WhisperState? + private var enhancementService: AIEnhancementService? + + private init() { + // Attempt to recover a session on startup in case of a crash. + recoverSession() + } + + func configure(whisperState: WhisperState, enhancementService: AIEnhancementService) { + self.whisperState = whisperState + self.enhancementService = enhancementService + } + + // Begins a new Power Mode session. It captures the current state, + // applies the new configuration, and saves the session. + func beginSession(with config: PowerModeConfig) async { + guard let whisperState = whisperState, let enhancementService = enhancementService else { + print("SessionManager not configured.") + return + } + + // 1. Capture the current application state. + let originalState = ApplicationState( + isEnhancementEnabled: enhancementService.isEnhancementEnabled, + useScreenCaptureContext: enhancementService.useScreenCaptureContext, + selectedPromptId: enhancementService.selectedPromptId?.uuidString, + selectedAIProvider: enhancementService.getAIService()?.selectedProvider.rawValue, + selectedAIModel: enhancementService.getAIService()?.currentModel, + selectedLanguage: UserDefaults.standard.string(forKey: "SelectedLanguage"), + transcriptionModelName: whisperState.currentTranscriptionModel?.name + ) + + // 2. Create and save the session. + let newSession = PowerModeSession( + id: UUID(), + startTime: Date(), + originalState: originalState + ) + saveSession(newSession) + + // 3. Apply the new configuration's settings. + await applyConfiguration(config) + } + + // Ends the current Power Mode session and restores the original state. + func endSession() async { + guard let session = loadSession() else { return } + + // Restore the original state from the session. + await restoreState(session.originalState) + + // Clear the session from UserDefaults. + clearSession() + } + + // Applies the settings from a PowerModeConfig. + private func applyConfiguration(_ config: PowerModeConfig) async { + guard let enhancementService = enhancementService else { return } + + await MainActor.run { + enhancementService.isEnhancementEnabled = config.isAIEnhancementEnabled + enhancementService.useScreenCaptureContext = config.useScreenCapture + + if config.isAIEnhancementEnabled { + if let promptId = config.selectedPrompt, let uuid = UUID(uuidString: promptId) { + enhancementService.selectedPromptId = uuid + } + + if let aiService = enhancementService.getAIService() { + if let providerName = config.selectedAIProvider, let provider = AIProvider(rawValue: providerName) { + aiService.selectedProvider = provider + } + if let model = config.selectedAIModel { + aiService.selectModel(model) + } + } + } + + if let language = config.selectedLanguage { + UserDefaults.standard.set(language, forKey: "SelectedLanguage") + NotificationCenter.default.post(name: .languageDidChange, object: nil) + } + } + + if let whisperState = whisperState, + let modelName = config.selectedTranscriptionModelName, + let selectedModel = await whisperState.allAvailableModels.first(where: { $0.name == modelName }), + whisperState.currentTranscriptionModel?.name != modelName { + await handleModelChange(to: selectedModel) + } + } + + // Restores the application state from a saved state object. + private func restoreState(_ state: ApplicationState) async { + guard let enhancementService = enhancementService else { return } + + await MainActor.run { + enhancementService.isEnhancementEnabled = state.isEnhancementEnabled + enhancementService.useScreenCaptureContext = state.useScreenCaptureContext + enhancementService.selectedPromptId = state.selectedPromptId.flatMap(UUID.init) + + if let aiService = enhancementService.getAIService() { + if let providerName = state.selectedAIProvider, let provider = AIProvider(rawValue: providerName) { + aiService.selectedProvider = provider + } + if let model = state.selectedAIModel { + aiService.selectModel(model) + } + } + + if let language = state.selectedLanguage { + UserDefaults.standard.set(language, forKey: "SelectedLanguage") + NotificationCenter.default.post(name: .languageDidChange, object: nil) + } + } + + if let whisperState = whisperState, + let modelName = state.transcriptionModelName, + let selectedModel = await whisperState.allAvailableModels.first(where: { $0.name == modelName }), + whisperState.currentTranscriptionModel?.name != modelName { + await handleModelChange(to: selectedModel) + } + } + + // Handles the logic for switching transcription models. + private func handleModelChange(to newModel: any TranscriptionModel) async { + guard let whisperState = whisperState else { return } + + await whisperState.setDefaultTranscriptionModel(newModel) + + switch newModel.provider { + case .local: + await whisperState.cleanupModelResources() + if let localModel = await whisperState.availableModels.first(where: { $0.name == newModel.name }) { + do { + try await whisperState.loadModel(localModel) + } catch { + // Log error appropriately + print("Power Mode: Failed to load local model '\(localModel.name)': \(error)") + } + } + case .parakeet: + await whisperState.cleanupModelResources() + // Parakeet models are loaded on demand, so we only need to clean up. + + default: + await whisperState.cleanupModelResources() + } + } + + private func recoverSession() { + guard let session = loadSession() else { return } + print("Recovering abandoned Power Mode session.") + Task { + await endSession() + } + } + + // MARK: - UserDefaults Persistence + + private func saveSession(_ session: PowerModeSession) { + do { + let data = try JSONEncoder().encode(session) + UserDefaults.standard.set(data, forKey: sessionKey) + } catch { + print("Error saving Power Mode session: \(error)") + } + } + + private func loadSession() -> PowerModeSession? { + guard let data = UserDefaults.standard.data(forKey: sessionKey) else { return nil } + do { + return try JSONDecoder().decode(PowerModeSession.self, from: data) + } catch { + print("Error loading Power Mode session: \(error)") + return nil + } + } + + private func clearSession() { + UserDefaults.standard.removeObject(forKey: sessionKey) + } +} diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index ae6f3c6..d7ca331 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -106,6 +106,11 @@ class WhisperState: NSObject, ObservableObject { super.init() + // Configure the session manager + if let enhancementService = enhancementService { + PowerModeSessionManager.shared.configure(whisperState: self, enhancementService: enhancementService) + } + // Set the whisperState reference after super.init() self.localTranscriptionService = LocalTranscriptionService(modelsDirectory: self.modelsDirectory, whisperState: self) @@ -214,6 +219,7 @@ class WhisperState: NSObject, ObservableObject { await MainActor.run { recordingState = .idle } + await PowerModeSessionManager.shared.endSession() await cleanupModelResources() return } @@ -367,6 +373,7 @@ class WhisperState: NSObject, ObservableObject { } await self.dismissMiniRecorder() + await PowerModeSessionManager.shared.endSession() } catch { do { @@ -400,6 +407,7 @@ class WhisperState: NSObject, ObservableObject { } await self.dismissMiniRecorder() + await PowerModeSessionManager.shared.endSession() } }