use log::{error, warn}; use serde::Serialize; use specta::Type; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_autostart::ManagerExt; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; use crate::managers::audio::AudioRecordingManager; use crate::settings::ShortcutBinding; use crate::settings::{ self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID, }; use crate::tray; use crate::ManagedToggleState; pub fn init_shortcuts(app: &AppHandle) { let default_bindings = settings::get_default_settings().bindings; let user_settings = settings::load_or_create_app_settings(app); // Register all default shortcuts, applying user customizations for (id, default_binding) in default_bindings { if id == "cancel" { continue; // Skip cancel shortcut, it will be registered dynamically } let binding = user_settings .bindings .get(&id) .cloned() .unwrap_or(default_binding); if let Err(e) = register_shortcut(app, binding) { error!("Failed to register shortcut {} during init: {}", id, e); } } } #[derive(Serialize, Type)] pub struct BindingResponse { success: bool, binding: Option, error: Option, } #[tauri::command] #[specta::specta] pub fn change_binding( app: AppHandle, id: String, binding: String, ) -> Result { let mut settings = settings::get_settings(&app); // Get the binding to modify let binding_to_modify = match settings.bindings.get(&id) { Some(binding) => binding.clone(), None => { let error_msg = format!("Binding with id '{}' not found", id); warn!("change_binding error: {}", error_msg); return Ok(BindingResponse { success: false, binding: None, error: Some(error_msg), }); } }; // If this is the cancel binding, just update the settings and return // It's managed dynamically, so we don't register/unregister here if id == "cancel" { if let Some(mut b) = settings.bindings.get(&id).cloned() { b.current_binding = binding; settings.bindings.insert(id.clone(), b.clone()); settings::write_settings(&app, settings); return Ok(BindingResponse { success: true, binding: Some(b.clone()), error: None, }); } } // Unregister the existing binding if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) { let error_msg = format!("Failed to unregister shortcut: {}", e); error!("change_binding error: {}", error_msg); } // Validate the new shortcut before we touch the current registration if let Err(e) = validate_shortcut_string(&binding) { warn!("change_binding validation error: {}", e); return Err(e); } // Create an updated binding let mut updated_binding = binding_to_modify; updated_binding.current_binding = binding; // Register the new binding if let Err(e) = register_shortcut(&app, updated_binding.clone()) { let error_msg = format!("Failed to register shortcut: {}", e); error!("change_binding error: {}", error_msg); return Ok(BindingResponse { success: false, binding: None, error: Some(error_msg), }); } // Update the binding in the settings settings.bindings.insert(id, updated_binding.clone()); // Save the settings settings::write_settings(&app, settings); // Return the updated binding Ok(BindingResponse { success: true, binding: Some(updated_binding), error: None, }) } #[tauri::command] #[specta::specta] pub fn reset_binding(app: AppHandle, id: String) -> Result { let binding = settings::get_stored_binding(&app, &id); return change_binding(app, id, binding.default_binding); } #[tauri::command] #[specta::specta] pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); // TODO if the setting is currently false, we probably want to // cancel any ongoing recordings or actions settings.push_to_talk = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.audio_feedback = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.audio_feedback_volume = volume; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); let parsed = match theme.as_str() { "marimba" => SoundTheme::Marimba, "pop" => SoundTheme::Pop, "custom" => SoundTheme::Custom, other => { warn!("Invalid sound theme '{}', defaulting to marimba", other); SoundTheme::Marimba } }; settings.sound_theme = parsed; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.translate_to_english = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.selected_language = language; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); let parsed = match position.as_str() { "none" => OverlayPosition::None, "top" => OverlayPosition::Top, "bottom" => OverlayPosition::Bottom, other => { warn!("Invalid overlay position '{}', defaulting to bottom", other); OverlayPosition::Bottom } }; settings.overlay_position = parsed; settings::write_settings(&app, settings); // Update overlay position without recreating window crate::utils::update_overlay_position(&app); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.debug_mode = enabled; settings::write_settings(&app, settings); // Emit event to notify frontend of debug mode change let _ = app.emit( "settings-changed", serde_json::json!({ "setting": "debug_mode", "value": enabled }), ); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_start_hidden_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.start_hidden = enabled; settings::write_settings(&app, settings); // Notify frontend let _ = app.emit( "settings-changed", serde_json::json!({ "setting": "start_hidden", "value": enabled }), ); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.autostart_enabled = enabled; settings::write_settings(&app, settings); // Apply the autostart setting immediately let autostart_manager = app.autolaunch(); if enabled { let _ = autostart_manager.enable(); } else { let _ = autostart_manager.disable(); } // Notify frontend let _ = app.emit( "settings-changed", serde_json::json!({ "setting": "autostart_enabled", "value": enabled }), ); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.update_checks_enabled = enabled; settings::write_settings(&app, settings); let _ = app.emit( "settings-changed", serde_json::json!({ "setting": "update_checks_enabled", "value": enabled }), ); Ok(()) } #[tauri::command] #[specta::specta] pub fn update_custom_words(app: AppHandle, words: Vec) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.custom_words = words; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_word_correction_threshold_setting( app: AppHandle, threshold: f64, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.word_correction_threshold = threshold; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); let parsed = match method.as_str() { "ctrl_v" => PasteMethod::CtrlV, "direct" => PasteMethod::Direct, "none" => PasteMethod::None, "shift_insert" => PasteMethod::ShiftInsert, "ctrl_shift_v" => PasteMethod::CtrlShiftV, other => { warn!("Invalid paste method '{}', defaulting to ctrl_v", other); PasteMethod::CtrlV } }; settings.paste_method = parsed; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); let parsed = match handling.as_str() { "dont_modify" => ClipboardHandling::DontModify, "copy_to_clipboard" => ClipboardHandling::CopyToClipboard, other => { warn!( "Invalid clipboard handling '{}', defaulting to dont_modify", other ); ClipboardHandling::DontModify } }; settings.clipboard_handling = parsed; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.post_process_enabled = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_post_process_base_url_setting( app: AppHandle, provider_id: String, base_url: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); let label = settings .post_process_provider(&provider_id) .map(|provider| provider.label.clone()) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; let provider = settings .post_process_provider_mut(&provider_id) .expect("Provider looked up above must exist"); if provider.id != "custom" { return Err(format!( "Provider '{}' does not allow editing the base URL", label )); } provider.base_url = base_url; settings::write_settings(&app, settings); Ok(()) } /// Generic helper to validate provider exists fn validate_provider_exists( settings: &settings::AppSettings, provider_id: &str, ) -> Result<(), String> { if !settings .post_process_providers .iter() .any(|provider| provider.id == provider_id) { return Err(format!("Provider '{}' not found", provider_id)); } Ok(()) } #[tauri::command] #[specta::specta] pub fn change_post_process_api_key_setting( app: AppHandle, provider_id: String, api_key: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); validate_provider_exists(&settings, &provider_id)?; settings.post_process_api_keys.insert(provider_id, api_key); settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_post_process_model_setting( app: AppHandle, provider_id: String, model: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); validate_provider_exists(&settings, &provider_id)?; settings.post_process_models.insert(provider_id, model); settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); validate_provider_exists(&settings, &provider_id)?; settings.post_process_provider_id = provider_id; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn add_post_process_prompt( app: AppHandle, name: String, prompt: String, ) -> Result { let mut settings = settings::get_settings(&app); // Generate unique ID using timestamp and random component let id = format!("prompt_{}", chrono::Utc::now().timestamp_millis()); let new_prompt = LLMPrompt { id: id.clone(), name, prompt, }; settings.post_process_prompts.push(new_prompt.clone()); settings::write_settings(&app, settings); Ok(new_prompt) } #[tauri::command] #[specta::specta] pub fn update_post_process_prompt( app: AppHandle, id: String, name: String, prompt: String, ) -> Result<(), String> { let mut settings = settings::get_settings(&app); if let Some(existing_prompt) = settings .post_process_prompts .iter_mut() .find(|p| p.id == id) { existing_prompt.name = name; existing_prompt.prompt = prompt; settings::write_settings(&app, settings); Ok(()) } else { Err(format!("Prompt with id '{}' not found", id)) } } #[tauri::command] #[specta::specta] pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); // Don't allow deleting the last prompt if settings.post_process_prompts.len() <= 1 { return Err("Cannot delete the last prompt".to_string()); } // Find and remove the prompt let original_len = settings.post_process_prompts.len(); settings.post_process_prompts.retain(|p| p.id != id); if settings.post_process_prompts.len() == original_len { return Err(format!("Prompt with id '{}' not found", id)); } // If the deleted prompt was selected, select the first one or None if settings.post_process_selected_prompt_id.as_ref() == Some(&id) { settings.post_process_selected_prompt_id = settings.post_process_prompts.first().map(|p| p.id.clone()); } settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub async fn fetch_post_process_models( app: AppHandle, provider_id: String, ) -> Result, String> { let settings = settings::get_settings(&app); // Find the provider let provider = settings .post_process_providers .iter() .find(|p| p.id == provider_id) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { return Ok(vec![APPLE_INTELLIGENCE_DEFAULT_MODEL_ID.to_string()]); } #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { return Err("Apple Intelligence is only available on Apple silicon Macs running macOS 15 or later.".to_string()); } } // Get API key let api_key = settings .post_process_api_keys .get(&provider_id) .cloned() .unwrap_or_default(); // Skip fetching if no API key for providers that typically need one if api_key.trim().is_empty() && provider.id != "custom" { return Err(format!( "API key is required for {}. Please add an API key to list available models.", provider.label )); } crate::llm_client::fetch_models(provider, api_key).await } #[tauri::command] #[specta::specta] pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); // Verify the prompt exists if !settings.post_process_prompts.iter().any(|p| p.id == id) { return Err(format!("Prompt with id '{}' not found", id)); } settings.post_process_selected_prompt_id = Some(id); settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.mute_while_recording = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.append_trailing_space = enabled; settings::write_settings(&app, settings); Ok(()) } #[tauri::command] #[specta::specta] pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.app_language = language.clone(); settings::write_settings(&app, settings); // Refresh the tray menu with the new language tray::update_tray_menu(&app, &tray::TrayIconState::Idle, Some(&language)); Ok(()) } /// Validate that a shortcut contains at least one non-modifier key. /// The tauri-plugin-global-shortcut library requires at least one main key. fn validate_shortcut_string(raw: &str) -> Result<(), String> { if raw.trim().is_empty() { return Err("Shortcut cannot be empty".into()); } let modifiers = [ "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", "windows", ]; let has_non_modifier = raw .split('+') .any(|part| !modifiers.contains(&part.trim().to_lowercase().as_str())); if has_non_modifier { Ok(()) } else { Err("Shortcut must include a main key (letter, number, F-key, etc.) in addition to modifiers".into()) } } /// Temporarily unregister a binding while the user is editing it in the UI. /// This avoids firing the action while keys are being recorded. #[tauri::command] #[specta::specta] pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { if let Err(e) = unregister_shortcut(&app, b) { error!("suspend_binding error for id '{}': {}", id, e); return Err(e); } } Ok(()) } /// Re-register the binding after the user has finished editing. #[tauri::command] #[specta::specta] pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> { if let Some(b) = settings::get_bindings(&app).get(&id).cloned() { if let Err(e) = register_shortcut(&app, b) { error!("resume_binding error for id '{}': {}", id, e); return Err(e); } } Ok(()) } pub fn register_cancel_shortcut(app: &AppHandle) { // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration #[cfg(target_os = "linux")] { let _ = app; return; } #[cfg(not(target_os = "linux"))] { let app_clone = app.clone(); tauri::async_runtime::spawn(async move { if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { if let Err(e) = register_shortcut(&app_clone, cancel_binding) { eprintln!("Failed to register cancel shortcut: {}", e); } } }); } } pub fn unregister_cancel_shortcut(app: &AppHandle) { // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration #[cfg(target_os = "linux")] { let _ = app; return; } #[cfg(not(target_os = "linux"))] { let app_clone = app.clone(); tauri::async_runtime::spawn(async move { if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() { // We ignore errors here as it might already be unregistered let _ = unregister_shortcut(&app_clone, cancel_binding); } }); } } pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { // Validate human-level rules first if let Err(e) = validate_shortcut_string(&binding.current_binding) { warn!( "_register_shortcut validation error for binding '{}': {}", binding.current_binding, e ); return Err(e); } // Parse shortcut and return error if it fails let shortcut = match binding.current_binding.parse::() { Ok(s) => s, Err(e) => { let error_msg = format!( "Failed to parse shortcut '{}': {}", binding.current_binding, e ); error!("_register_shortcut parse error: {}", error_msg); return Err(error_msg); } }; // Prevent duplicate registrations that would silently shadow one another if app.global_shortcut().is_registered(shortcut) { let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding); warn!("_register_shortcut duplicate error: {}", error_msg); return Err(error_msg); } // Clone binding.id for use in the closure let binding_id_for_closure = binding.id.clone(); app.global_shortcut() .on_shortcut(shortcut, move |ah, scut, event| { if scut == &shortcut { let shortcut_string = scut.into_string(); let settings = get_settings(ah); if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) { if binding_id_for_closure == "cancel" { let audio_manager = ah.state::>(); if audio_manager.is_recording() && event.state == ShortcutState::Pressed { action.start(ah, &binding_id_for_closure, &shortcut_string); } return; } else if settings.push_to_talk { if event.state == ShortcutState::Pressed { action.start(ah, &binding_id_for_closure, &shortcut_string); } else if event.state == ShortcutState::Released { action.stop(ah, &binding_id_for_closure, &shortcut_string); } } else { // Toggle mode: toggle on press only if event.state == ShortcutState::Pressed { // Determine action and update state while holding the lock, // but RELEASE the lock before calling the action to avoid deadlocks. // (Actions may need to acquire the lock themselves, e.g., cancel_current_operation) let should_start: bool; { let toggle_state_manager = ah.state::(); let mut states = toggle_state_manager .lock() .expect("Failed to lock toggle state manager"); let is_currently_active = states .active_toggles .entry(binding_id_for_closure.clone()) .or_insert(false); should_start = !*is_currently_active; *is_currently_active = should_start; } // Lock released here // Now call the action without holding the lock if should_start { action.start(ah, &binding_id_for_closure, &shortcut_string); } else { action.stop(ah, &binding_id_for_closure, &shortcut_string); } } } } else { warn!( "No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}", binding_id_for_closure, shortcut_string, event.state ); } } }) .map_err(|e| { let error_msg = format!("Couldn't register shortcut '{}': {}", binding.current_binding, e); error!("_register_shortcut registration error: {}", error_msg); error_msg })?; Ok(()) } pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { let shortcut = match binding.current_binding.parse::() { Ok(s) => s, Err(e) => { let error_msg = format!( "Failed to parse shortcut '{}' for unregistration: {}", binding.current_binding, e ); error!("_unregister_shortcut parse error: {}", error_msg); return Err(error_msg); } }; app.global_shortcut().unregister(shortcut).map_err(|e| { let error_msg = format!( "Failed to unregister shortcut '{}': {}", binding.current_binding, e ); error!("_unregister_shortcut error: {}", error_msg); error_msg })?; Ok(()) }