import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import { listen } from "@tauri-apps/api/event"; import type { AppSettings as Settings, AudioDevice, WhisperAcceleratorSetting, OrtAcceleratorSetting, } from "@/bindings"; import { commands } from "@/bindings"; interface SettingsStore { settings: Settings | null; defaultSettings: Settings | null; isLoading: boolean; isUpdating: Record; audioDevices: AudioDevice[]; outputDevices: AudioDevice[]; customSounds: { start: boolean; stop: boolean }; postProcessModelOptions: Record; // Actions initialize: () => Promise; loadDefaultSettings: () => Promise; updateSetting: ( key: K, value: Settings[K], ) => Promise; resetSetting: (key: keyof Settings) => Promise; refreshSettings: () => Promise; refreshAudioDevices: () => Promise; refreshOutputDevices: () => Promise; updateBinding: (id: string, binding: string) => Promise; resetBinding: (id: string) => Promise; getSetting: (key: K) => Settings[K] | undefined; isUpdatingKey: (key: string) => boolean; playTestSound: (soundType: "start" | "stop") => Promise; checkCustomSounds: () => Promise; setPostProcessProvider: (providerId: string) => Promise; updatePostProcessSetting: ( settingType: "base_url" | "api_key" | "model", providerId: string, value: string, ) => Promise; updatePostProcessBaseUrl: ( providerId: string, baseUrl: string, ) => Promise; updatePostProcessApiKey: ( providerId: string, apiKey: string, ) => Promise; updatePostProcessModel: (providerId: string, model: string) => Promise; fetchPostProcessModels: (providerId: string) => Promise; setPostProcessModelOptions: (providerId: string, models: string[]) => void; // Internal state setters setSettings: (settings: Settings | null) => void; setDefaultSettings: (defaultSettings: Settings | null) => void; setLoading: (loading: boolean) => void; setUpdating: (key: string, updating: boolean) => void; setAudioDevices: (devices: AudioDevice[]) => void; setOutputDevices: (devices: AudioDevice[]) => void; setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void; } // Note: Default settings are now fetched from Rust via commands.getDefaultSettings() // This ensures platform-specific defaults (like overlay_position, shortcuts, paste_method) work correctly const DEFAULT_AUDIO_DEVICE: AudioDevice = { index: "default", name: "Default", is_default: true, }; const settingUpdaters: { [K in keyof Settings]?: (value: Settings[K]) => Promise; } = { always_on_microphone: (value) => commands.updateMicrophoneMode(value as boolean), audio_feedback: (value) => commands.changeAudioFeedbackSetting(value as boolean), audio_feedback_volume: (value) => commands.changeAudioFeedbackVolumeSetting(value as number), sound_theme: (value) => commands.changeSoundThemeSetting(value as string), start_hidden: (value) => commands.changeStartHiddenSetting(value as boolean), autostart_enabled: (value) => commands.changeAutostartSetting(value as boolean), update_checks_enabled: (value) => commands.changeUpdateChecksSetting(value as boolean), push_to_talk: (value) => commands.changePttSetting(value as boolean), selected_microphone: (value) => commands.setSelectedMicrophone( (value as string) === "Default" || value === null ? "default" : (value as string), ), clamshell_microphone: (value) => commands.setClamshellMicrophone( (value as string) === "Default" ? "default" : (value as string), ), selected_output_device: (value) => commands.setSelectedOutputDevice( (value as string) === "Default" || value === null ? "default" : (value as string), ), recording_retention_period: (value) => commands.updateRecordingRetentionPeriod(value as string), translate_to_english: (value) => commands.changeTranslateToEnglishSetting(value as boolean), selected_language: (value) => commands.changeSelectedLanguageSetting(value as string), overlay_position: (value) => commands.changeOverlayPositionSetting(value as string), debug_mode: (value) => commands.changeDebugModeSetting(value as boolean), custom_words: (value) => commands.updateCustomWords(value as string[]), word_correction_threshold: (value) => commands.changeWordCorrectionThresholdSetting(value as number), paste_method: (value) => commands.changePasteMethodSetting(value as string), typing_tool: (value) => commands.changeTypingToolSetting(value as string), external_script_path: (value) => commands.changeExternalScriptPathSetting(value as string | null), clipboard_handling: (value) => commands.changeClipboardHandlingSetting(value as string), auto_submit: (value) => commands.changeAutoSubmitSetting(value as boolean), auto_submit_key: (value) => commands.changeAutoSubmitKeySetting(value as string), history_limit: (value) => commands.updateHistoryLimit(value as number), post_process_enabled: (value) => commands.changePostProcessEnabledSetting(value as boolean), post_process_selected_prompt_id: (value) => commands.setPostProcessSelectedPrompt(value as string), mute_while_recording: (value) => commands.changeMuteWhileRecordingSetting(value as boolean), append_trailing_space: (value) => commands.changeAppendTrailingSpaceSetting(value as boolean), log_level: (value) => commands.setLogLevel(value as any), app_language: (value) => commands.changeAppLanguageSetting(value as string), experimental_enabled: (value) => commands.changeExperimentalEnabledSetting(value as boolean), show_tray_icon: (value) => commands.changeShowTrayIconSetting(value as boolean), whisper_accelerator: (value) => commands.changeWhisperAcceleratorSetting( value as WhisperAcceleratorSetting, ), ort_accelerator: (value) => commands.changeOrtAcceleratorSetting(value as OrtAcceleratorSetting), extra_recording_buffer_ms: (value) => commands.changeExtraRecordingBufferSetting(value as number), }; export const useSettingsStore = create()( subscribeWithSelector((set, get) => ({ settings: null, defaultSettings: null, isLoading: true, isUpdating: {}, audioDevices: [], outputDevices: [], customSounds: { start: false, stop: false }, postProcessModelOptions: {}, // Internal setters setSettings: (settings) => set({ settings }), setDefaultSettings: (defaultSettings) => set({ defaultSettings }), setLoading: (isLoading) => set({ isLoading }), setUpdating: (key, updating) => set((state) => ({ isUpdating: { ...state.isUpdating, [key]: updating }, })), setAudioDevices: (audioDevices) => set({ audioDevices }), setOutputDevices: (outputDevices) => set({ outputDevices }), setCustomSounds: (customSounds) => set({ customSounds }), // Getters getSetting: (key) => get().settings?.[key], isUpdatingKey: (key) => get().isUpdating[key] || false, // Load settings from store refreshSettings: async () => { try { const result = await commands.getAppSettings(); if (result.status === "ok") { const settings = result.data; const normalizedSettings: Settings = { ...settings, always_on_microphone: settings.always_on_microphone ?? false, selected_microphone: settings.selected_microphone ?? "Default", clamshell_microphone: settings.clamshell_microphone ?? "Default", selected_output_device: settings.selected_output_device ?? "Default", }; set({ settings: normalizedSettings, isLoading: false }); } else { console.error("Failed to load settings:", result.error); set({ isLoading: false }); } } catch (error) { console.error("Failed to load settings:", error); set({ isLoading: false }); } }, // Load audio devices refreshAudioDevices: async () => { try { const result = await commands.getAvailableMicrophones(); if (result.status === "ok") { const devicesWithDefault = [ DEFAULT_AUDIO_DEVICE, ...result.data.filter( (d) => d.name !== "Default" && d.name !== "default", ), ]; set({ audioDevices: devicesWithDefault }); } else { set({ audioDevices: [DEFAULT_AUDIO_DEVICE] }); } } catch (error) { console.error("Failed to load audio devices:", error); set({ audioDevices: [DEFAULT_AUDIO_DEVICE] }); } }, // Load output devices refreshOutputDevices: async () => { try { const result = await commands.getAvailableOutputDevices(); if (result.status === "ok") { const devicesWithDefault = [ DEFAULT_AUDIO_DEVICE, ...result.data.filter( (d) => d.name !== "Default" && d.name !== "default", ), ]; set({ outputDevices: devicesWithDefault }); } else { set({ outputDevices: [DEFAULT_AUDIO_DEVICE] }); } } catch (error) { console.error("Failed to load output devices:", error); set({ outputDevices: [DEFAULT_AUDIO_DEVICE] }); } }, // Play a test sound playTestSound: async (soundType: "start" | "stop") => { try { await commands.playTestSound(soundType); } catch (error) { console.error(`Failed to play test sound (${soundType}):`, error); } }, checkCustomSounds: async () => { try { const sounds = await commands.checkCustomSounds(); get().setCustomSounds(sounds); } catch (error) { console.error("Failed to check custom sounds:", error); } }, // Update a specific setting updateSetting: async ( key: K, value: Settings[K], ) => { const { settings, setUpdating } = get(); const updateKey = String(key); const originalValue = settings?.[key]; setUpdating(updateKey, true); try { set((state) => ({ settings: state.settings ? { ...state.settings, [key]: value } : null, })); const updater = settingUpdaters[key]; if (updater) { await updater(value); } else if (key !== "bindings" && key !== "selected_model") { console.warn(`No handler for setting: ${String(key)}`); } } catch (error) { console.error(`Failed to update setting ${String(key)}:`, error); if (settings) { set({ settings: { ...settings, [key]: originalValue } }); } } finally { setUpdating(updateKey, false); } }, // Reset a setting to its default value resetSetting: async (key) => { const { defaultSettings } = get(); if (defaultSettings) { const defaultValue = defaultSettings[key]; if (defaultValue !== undefined) { await get().updateSetting(key, defaultValue as any); } } }, // Update a specific binding updateBinding: async (id, binding) => { const { settings, setUpdating } = get(); const updateKey = `binding_${id}`; const originalBinding = settings?.bindings?.[id]?.current_binding; setUpdating(updateKey, true); try { // Optimistic update set((state) => ({ settings: state.settings ? { ...state.settings, bindings: { ...state.settings.bindings, [id]: { ...state.settings.bindings[id]!, current_binding: binding, }, }, } : null, })); const result = await commands.changeBinding(id, binding); // Check if the command executed successfully if (result.status === "error") { throw new Error(result.error); } // Check if the binding change was successful if (!result.data.success) { throw new Error(result.data.error || "Failed to update binding"); } } catch (error) { console.error(`Failed to update binding ${id}:`, error); // Rollback on error if (originalBinding && get().settings) { set((state) => ({ settings: state.settings ? { ...state.settings, bindings: { ...state.settings.bindings, [id]: { ...state.settings.bindings[id]!, current_binding: originalBinding, }, }, } : null, })); } // Re-throw to let the caller know it failed throw error; } finally { setUpdating(updateKey, false); } }, // Reset a specific binding resetBinding: async (id) => { const { setUpdating, refreshSettings } = get(); const updateKey = `binding_${id}`; setUpdating(updateKey, true); try { await commands.resetBinding(id); await refreshSettings(); } catch (error) { console.error(`Failed to reset binding ${id}:`, error); } finally { setUpdating(updateKey, false); } }, setPostProcessProvider: async (providerId) => { const { settings, setUpdating, refreshSettings, setPostProcessModelOptions, } = get(); const updateKey = "post_process_provider_id"; const previousId = settings?.post_process_provider_id ?? null; setUpdating(updateKey, true); if (settings) { set((state) => ({ settings: state.settings ? { ...state.settings, post_process_provider_id: providerId } : null, })); } // Clear cached model options for the new provider so the dropdown // doesn't show stale models from a previous fetch or base_url. setPostProcessModelOptions(providerId, []); try { await commands.setPostProcessProvider(providerId); await refreshSettings(); } catch (error) { console.error("Failed to set post-process provider:", error); if (previousId !== null) { set((state) => ({ settings: state.settings ? { ...state.settings, post_process_provider_id: previousId } : null, })); } } finally { setUpdating(updateKey, false); } }, // Generic updater for post-processing provider settings updatePostProcessSetting: async ( settingType: "base_url" | "api_key" | "model", providerId: string, value: string, ) => { const { setUpdating, refreshSettings } = get(); const updateKey = `post_process_${settingType}:${providerId}`; setUpdating(updateKey, true); try { if (settingType === "base_url") { await commands.changePostProcessBaseUrlSetting(providerId, value); } else if (settingType === "api_key") { await commands.changePostProcessApiKeySetting(providerId, value); } else if (settingType === "model") { await commands.changePostProcessModelSetting(providerId, value); } await refreshSettings(); } catch (error) { console.error( `Failed to update post-process ${settingType.replace("_", " ")}:`, error, ); } finally { setUpdating(updateKey, false); } }, updatePostProcessBaseUrl: async (providerId, baseUrl) => { const { setUpdating, refreshSettings } = get(); const updateKey = `post_process_base_url:${providerId}`; setUpdating(updateKey, true); try { // Persist the new base URL first. const urlResult = await commands.changePostProcessBaseUrlSetting( providerId, baseUrl, ); if (urlResult.status === "error") { console.error("Failed to persist base URL:", urlResult.error); return; } // Reset the stored model since the previous value is almost certainly // invalid for the new endpoint (e.g. switching Custom from Groq to // Cerebras). Only proceed if the reset succeeds. const modelResult = await commands.changePostProcessModelSetting( providerId, "", ); if (modelResult.status === "error") { console.error("Failed to reset model setting:", modelResult.error); return; } // Clear cached model options only after both backend writes succeed. set((state) => ({ postProcessModelOptions: { ...state.postProcessModelOptions, [providerId]: [], }, })); // Single refresh after both backend writes. await refreshSettings(); } catch (error) { console.error("Failed to update post-process base URL:", error); } finally { setUpdating(updateKey, false); } }, updatePostProcessApiKey: async (providerId, apiKey) => { // Clear cached models when API key changes - user should click refresh after set((state) => ({ postProcessModelOptions: { ...state.postProcessModelOptions, [providerId]: [], }, })); return get().updatePostProcessSetting("api_key", providerId, apiKey); }, updatePostProcessModel: async (providerId, model) => { return get().updatePostProcessSetting("model", providerId, model); }, fetchPostProcessModels: async (providerId) => { const updateKey = `post_process_models_fetch:${providerId}`; const { setUpdating, setPostProcessModelOptions } = get(); setUpdating(updateKey, true); try { // Call Tauri backend command instead of fetch const result = await commands.fetchPostProcessModels(providerId); if (result.status === "ok") { setPostProcessModelOptions(providerId, result.data); return result.data; } else { console.error("Failed to fetch models:", result.error); return []; } } catch (error) { console.error("Failed to fetch models:", error); // Don't cache empty array on error - let user retry return []; } finally { setUpdating(updateKey, false); } }, setPostProcessModelOptions: (providerId, models) => set((state) => ({ postProcessModelOptions: { ...state.postProcessModelOptions, [providerId]: models, }, })), // Load default settings from Rust loadDefaultSettings: async () => { try { const result = await commands.getDefaultSettings(); if (result.status === "ok") { set({ defaultSettings: result.data }); } else { console.error("Failed to load default settings:", result.error); } } catch (error) { console.error("Failed to load default settings:", error); } }, // Initialize everything initialize: async () => { const { refreshSettings, checkCustomSounds, loadDefaultSettings } = get(); // Note: Audio devices are NOT refreshed here. The frontend (App.tsx) // is responsible for calling refreshAudioDevices/refreshOutputDevices // after onboarding completes. This avoids triggering permission dialogs // on macOS before the user is ready. await Promise.all([ loadDefaultSettings(), refreshSettings(), checkCustomSounds(), ]); // Re-fetch settings when the backend changes them (e.g. language // reset during model switch). The backend is the source of truth. listen("model-state-changed", () => { get().refreshSettings(); }); }, })), );