From 4c32710d5ea2a7cdab6276f14094bdfa5c1788a7 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Thu, 5 Jun 2025 08:41:29 +0545 Subject: [PATCH] feat: Improve settings import/export with API key guidance --- VoiceInk/Services/ImportExportService.swift | 260 ++++++++++++++++++++ VoiceInk/Views/Settings/SettingsView.swift | 47 ++++ 2 files changed, 307 insertions(+) create mode 100644 VoiceInk/Services/ImportExportService.swift diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift new file mode 100644 index 0000000..ab964c0 --- /dev/null +++ b/VoiceInk/Services/ImportExportService.swift @@ -0,0 +1,260 @@ +import Foundation +import AppKit +import UniformTypeIdentifiers +import KeyboardShortcuts +import LaunchAtLogin + +struct GeneralSettings: Codable { + let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut? + let isPushToTalkEnabled: Bool? + let pushToTalkKeyRawValue: String? + let launchAtLoginEnabled: Bool? + let isMenuBarOnly: Bool? + let useAppleScriptPaste: Bool? + let recorderType: String? + let isAudioCleanupEnabled: Bool? + let audioRetentionPeriod: Int? + let isAutoCopyEnabled: Bool? + let isSoundFeedbackEnabled: Bool? + let isSystemMuteEnabled: Bool? +} + +struct VoiceInkExportedSettings: Codable { + let version: String + let customPrompts: [CustomPrompt] + let powerModeConfigs: [PowerModeConfig] + let defaultPowerModeConfig: PowerModeConfig + let dictionaryItems: [DictionaryItem]? + let wordReplacements: [String: String]? + let generalSettings: GeneralSettings? +} + +class ImportExportService { + static let shared = ImportExportService() + private let currentSettingsVersion: String + private let dictionaryItemsKey = "CustomDictionaryItems" + private let wordReplacementsKey = "wordReplacements" + + private let keyIsPushToTalkEnabled = "isPushToTalkEnabled" + private let keyPushToTalkKey = "pushToTalkKey" + private let keyIsMenuBarOnly = "IsMenuBarOnly" + private let keyUseAppleScriptPaste = "UseAppleScriptPaste" + private let keyRecorderType = "RecorderType" + private let keyIsAudioCleanupEnabled = "IsAudioCleanupEnabled" + private let keyAudioRetentionPeriod = "AudioRetentionPeriod" + private let keyIsAutoCopyEnabled = "IsAutoCopyEnabled" + private let keyIsSoundFeedbackEnabled = "isSoundFeedbackEnabled" + private let keyIsSystemMuteEnabled = "isSystemMuteEnabled" + + private init() { + if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { + self.currentSettingsVersion = version + } else { + self.currentSettingsVersion = "0.0.0" + } + } + + @MainActor + func exportSettings(enhancementService: AIEnhancementService, whisperPrompt: WhisperPrompt, hotkeyManager: HotkeyManager, menuBarManager: MenuBarManager, mediaController: MediaController, soundManager: SoundManager, whisperState: WhisperState) { + let powerModeManager = PowerModeManager.shared + + let exportablePrompts = enhancementService.customPrompts.filter { !$0.isPredefined } + + let powerConfigs = powerModeManager.configurations + let defaultPowerConfig = powerModeManager.defaultConfig + + var exportedDictionaryItems: [DictionaryItem]? = nil + if let data = UserDefaults.standard.data(forKey: dictionaryItemsKey), + let items = try? JSONDecoder().decode([DictionaryItem].self, from: data) { + exportedDictionaryItems = items + } + + let exportedWordReplacements = UserDefaults.standard.dictionary(forKey: wordReplacementsKey) as? [String: String] + + let generalSettingsToExport = GeneralSettings( + toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder), + isPushToTalkEnabled: hotkeyManager.isPushToTalkEnabled, + pushToTalkKeyRawValue: hotkeyManager.pushToTalkKey.rawValue, + launchAtLoginEnabled: LaunchAtLogin.isEnabled, + isMenuBarOnly: menuBarManager.isMenuBarOnly, + useAppleScriptPaste: UserDefaults.standard.bool(forKey: keyUseAppleScriptPaste), + recorderType: whisperState.recorderType, + isAudioCleanupEnabled: UserDefaults.standard.bool(forKey: keyIsAudioCleanupEnabled), + audioRetentionPeriod: UserDefaults.standard.integer(forKey: keyAudioRetentionPeriod), + isAutoCopyEnabled: whisperState.isAutoCopyEnabled, + isSoundFeedbackEnabled: soundManager.isEnabled, + isSystemMuteEnabled: mediaController.isSystemMuteEnabled + ) + + let settingsToExport = VoiceInkExportedSettings( + version: currentSettingsVersion, + customPrompts: exportablePrompts, + powerModeConfigs: powerConfigs, + defaultPowerModeConfig: defaultPowerConfig, + dictionaryItems: exportedDictionaryItems, + wordReplacements: exportedWordReplacements, + generalSettings: generalSettingsToExport + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + do { + let jsonData = try encoder.encode(settingsToExport) + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [UTType.json] + savePanel.nameFieldStringValue = "VoiceInk_Settings_Backup.json" + savePanel.title = "Export VoiceInk Settings" + savePanel.message = "Choose a location to save your settings." + + DispatchQueue.main.async { + if savePanel.runModal() == .OK { + if let url = savePanel.url { + do { + try jsonData.write(to: url) + self.showAlert(title: "Export Successful", message: "Your settings have been successfully exported to \(url.lastPathComponent).") + } catch { + self.showAlert(title: "Export Error", message: "Could not save settings to file: \(error.localizedDescription)") + } + } + } else { + self.showAlert(title: "Export Canceled", message: "The settings export operation was canceled.") + } + } + } catch { + self.showAlert(title: "Export Error", message: "Could not encode settings to JSON: \(error.localizedDescription)") + } + } + + @MainActor + func importSettings(enhancementService: AIEnhancementService, whisperPrompt: WhisperPrompt, hotkeyManager: HotkeyManager, menuBarManager: MenuBarManager, mediaController: MediaController, soundManager: SoundManager, whisperState: WhisperState) { + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [UTType.json] + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.title = "Import VoiceInk Settings" + openPanel.message = "Choose a settings file to import. This will overwrite ALL settings (prompts, power modes, dictionary, general app settings)." + + DispatchQueue.main.async { + if openPanel.runModal() == .OK { + guard let url = openPanel.url else { + self.showAlert(title: "Import Error", message: "Could not get the file URL from the open panel.") + return + } + + do { + let jsonData = try Data(contentsOf: url) + let decoder = JSONDecoder() + let importedSettings = try decoder.decode(VoiceInkExportedSettings.self, from: jsonData) + + if importedSettings.version != self.currentSettingsVersion { + self.showAlert(title: "Version Mismatch", message: "The imported settings file (version \(importedSettings.version)) is from a different version than your application (version \(self.currentSettingsVersion)). Proceeding with import, but be aware of potential incompatibilities.") + } + + let predefinedPrompts = enhancementService.customPrompts.filter { $0.isPredefined } + enhancementService.customPrompts = predefinedPrompts + importedSettings.customPrompts + + let powerModeManager = PowerModeManager.shared + powerModeManager.configurations = importedSettings.powerModeConfigs + powerModeManager.defaultConfig = importedSettings.defaultPowerModeConfig + powerModeManager.saveConfigurations() + powerModeManager.updateConfiguration(powerModeManager.defaultConfig) + + if let itemsToImport = importedSettings.dictionaryItems { + Task { + await whisperPrompt.saveDictionaryItems(itemsToImport) + } + } else { + print("No dictionary items (for spelling) found in the imported file. Existing items remain unchanged.") + } + + if let replacementsToImport = importedSettings.wordReplacements { + UserDefaults.standard.set(replacementsToImport, forKey: self.wordReplacementsKey) + } else { + print("No word replacements found in the imported file. Existing replacements remain unchanged.") + } + + if let general = importedSettings.generalSettings { + if let shortcut = general.toggleMiniRecorderShortcut { + KeyboardShortcuts.setShortcut(shortcut, for: .toggleMiniRecorder) + } + if let pttEnabled = general.isPushToTalkEnabled { + hotkeyManager.isPushToTalkEnabled = pttEnabled + } + if let pttKeyRaw = general.pushToTalkKeyRawValue, + let pttKey = HotkeyManager.PushToTalkKey(rawValue: pttKeyRaw) { + hotkeyManager.pushToTalkKey = pttKey + } + if let launch = general.launchAtLoginEnabled { + LaunchAtLogin.isEnabled = launch + } + if let menuOnly = general.isMenuBarOnly { + menuBarManager.isMenuBarOnly = menuOnly + } + if let appleScriptPaste = general.useAppleScriptPaste { + UserDefaults.standard.set(appleScriptPaste, forKey: self.keyUseAppleScriptPaste) + } + if let recType = general.recorderType { + whisperState.recorderType = recType + } + if let audioCleanup = general.isAudioCleanupEnabled { + UserDefaults.standard.set(audioCleanup, forKey: self.keyIsAudioCleanupEnabled) + } + if let audioRetention = general.audioRetentionPeriod { + UserDefaults.standard.set(audioRetention, forKey: self.keyAudioRetentionPeriod) + } + if let autoCopy = general.isAutoCopyEnabled { + whisperState.isAutoCopyEnabled = autoCopy + } + if let soundFeedback = general.isSoundFeedbackEnabled { + soundManager.isEnabled = soundFeedback + } + if let muteSystem = general.isSystemMuteEnabled { + mediaController.isSystemMuteEnabled = muteSystem + } + } + + self.showRestartAlert(message: "Settings imported successfully from \(url.lastPathComponent). All settings (including general app settings) have been applied.") + + } catch { + self.showAlert(title: "Import Error", message: "Error importing settings: \(error.localizedDescription). The file might be corrupted or not in the correct format.") + } + } else { + self.showAlert(title: "Import Canceled", message: "The settings import operation was canceled.") + } + } + } + + private func showAlert(title: String, message: String) { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + } + + private func showRestartAlert(message: String) { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Import Successful" + alert.informativeText = message + "\n\nIMPORTANT: If you were using AI enhancement features, please make sure to reconfigure your API keys in the Enhancement section.\n\nIt is recommended to restart VoiceInk for all changes to take full effect." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Configure API Keys") + + let response = alert.runModal() + if response == .alertSecondButtonReturn { + NotificationCenter.default.post( + name: .navigateToDestination, + object: nil, + userInfo: ["destination": "Enhancement"] + ) + } + } + } +} \ No newline at end of file diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index 6878e75..ea4fecb 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -10,6 +10,7 @@ struct SettingsView: View { @EnvironmentObject private var menuBarManager: MenuBarManager @EnvironmentObject private var hotkeyManager: HotkeyManager @EnvironmentObject private var whisperState: WhisperState + @EnvironmentObject private var enhancementService: AIEnhancementService @StateObject private var deviceManager = AudioDeviceManager.shared @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true @State private var showResetOnboardingAlert = false @@ -191,6 +192,52 @@ struct SettingsView: View { AudioCleanupSettingsView() } + // Data Management Section + SettingsSection( + icon: "arrow.up.arrow.down.circle", + title: "Data Management", + subtitle: "Import or export your settings" + ) { + VStack(alignment: .leading, spacing: 12) { + Text("Export your custom prompts, power modes, word replacements, keyboard shortcuts, and app preferences to a backup file. API keys are not included in the export.") + .settingsDescription() + + HStack(spacing: 12) { + Button { + ImportExportService.shared.importSettings( + enhancementService: enhancementService, + whisperPrompt: whisperState.whisperPrompt, + hotkeyManager: hotkeyManager, + menuBarManager: menuBarManager, + mediaController: MediaController.shared, + soundManager: SoundManager.shared, + whisperState: whisperState + ) + } label: { + Label("Import Settings...", systemImage: "arrow.down.doc") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + + Button { + ImportExportService.shared.exportSettings( + enhancementService: enhancementService, + whisperPrompt: whisperState.whisperPrompt, + hotkeyManager: hotkeyManager, + menuBarManager: menuBarManager, + mediaController: MediaController.shared, + soundManager: SoundManager.shared, + whisperState: whisperState + ) + } label: { + Label("Export Settings...", systemImage: "arrow.up.doc") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + } + } + // Reset Onboarding Section SettingsSection( icon: "arrow.counterclockwise",