Created
October 27, 2025 09:22
-
-
Save dsebastien/539126349f0ba0d4633f2b16bfa4ed2d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use crate::audio_feedback::{play_feedback_sound, SoundType}; | |
| use crate::managers::audio::AudioRecordingManager; | |
| use crate::managers::transcription::TranscriptionManager; | |
| use crate::overlay::{show_recording_overlay, show_transcribing_overlay}; | |
| use crate::settings::get_settings; | |
| use crate::time_utils::format_timestamp; | |
| use crate::tray::{change_tray_icon, TrayIconState}; | |
| use crate::utils; | |
| use crate::ManagedToggleState; | |
| use log::debug; | |
| use once_cell::sync::Lazy; | |
| use std::collections::HashMap; | |
| use std::sync::Arc; | |
| use std::time::Instant; | |
| use tauri::AppHandle; | |
| use tauri::Manager; | |
| // Shortcut Action Trait | |
| pub trait ShortcutAction: Send + Sync { | |
| fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str); | |
| fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str); | |
| } | |
| // Transcribe Action | |
| struct TranscribeAction; | |
| impl ShortcutAction for TranscribeAction { | |
| fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { | |
| let start_time = Instant::now(); | |
| eprintln!("{} [ACTION] TranscribeAction::start called for binding: {}", format_timestamp(), binding_id); | |
| // Load model in the background | |
| let tm = app.state::<Arc<TranscriptionManager>>(); | |
| tm.initiate_model_load(); | |
| let binding_id = binding_id.to_string(); | |
| change_tray_icon(app, TrayIconState::Recording); | |
| show_recording_overlay(app); | |
| let rm = app.state::<Arc<AudioRecordingManager>>(); | |
| // Get the microphone mode to determine audio feedback timing | |
| let settings = get_settings(app); | |
| let is_always_on = settings.always_on_microphone; | |
| eprintln!("{} [ACTION] Microphone mode - always_on: {}", format_timestamp(), is_always_on); | |
| let recording_started = if is_always_on { | |
| // Always-on mode: Play audio feedback immediately | |
| eprintln!("{} [ACTION] Always-on mode: Playing audio feedback immediately", format_timestamp()); | |
| play_feedback_sound(app, SoundType::Start); | |
| let started = rm.try_start_recording(&binding_id); | |
| eprintln!("{} [ACTION] Recording started: {}", format_timestamp(), started); | |
| started | |
| } else { | |
| // On-demand mode: Start recording first, then play audio feedback | |
| // This allows the microphone to be activated before playing the sound | |
| eprintln!("{} [ACTION] On-demand mode: Starting recording first, then audio feedback", format_timestamp()); | |
| let recording_start_time = Instant::now(); | |
| let started = rm.try_start_recording(&binding_id); | |
| if started { | |
| eprintln!("{} [ACTION] Recording started in {:?}", format_timestamp(), recording_start_time.elapsed()); | |
| // Small delay to ensure microphone stream is active | |
| let app_clone = app.clone(); | |
| let ts = format_timestamp(); | |
| std::thread::spawn(move || { | |
| std::thread::sleep(std::time::Duration::from_millis(100)); | |
| eprintln!("{} [ACTION] Playing delayed audio feedback", ts); | |
| play_feedback_sound(&app_clone, SoundType::Start); | |
| }); | |
| } else { | |
| eprintln!("{} [ACTION] Failed to start recording", format_timestamp()); | |
| } | |
| started | |
| }; | |
| // Clear busy flag for toggle mode (PTT mode doesn't set it on start) | |
| let settings = get_settings(app); | |
| if !settings.push_to_talk { | |
| // Toggle mode: clear busy flag after start completes | |
| if let Some(toggle_state) = app.try_state::<ManagedToggleState>() { | |
| if let Ok(mut states) = toggle_state.lock() { | |
| states.busy_states.insert(binding_id.clone(), false); | |
| debug!("Busy flag cleared for binding: {} (toggle mode)", binding_id); | |
| } | |
| } | |
| } | |
| if !recording_started { | |
| // If recording failed to start, reset UI state and clear busy flag | |
| utils::hide_recording_overlay(app); | |
| change_tray_icon(app, TrayIconState::Idle); | |
| // Always clear busy flag on failure, regardless of mode | |
| if let Some(toggle_state) = app.try_state::<ManagedToggleState>() { | |
| if let Ok(mut states) = toggle_state.lock() { | |
| states.busy_states.insert(binding_id.clone(), false); | |
| debug!("Busy flag cleared due to failed start for binding: {}", binding_id); | |
| } | |
| } | |
| } | |
| debug!( | |
| "TranscribeAction::start completed in {:?}", | |
| start_time.elapsed() | |
| ); | |
| } | |
| fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { | |
| let stop_time = Instant::now(); | |
| eprintln!("{} [ACTION] TranscribeAction::stop called for binding: {}", format_timestamp(), binding_id); | |
| let ah = app.clone(); | |
| let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>()); | |
| let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>()); | |
| change_tray_icon(app, TrayIconState::Transcribing); | |
| show_transcribing_overlay(app); | |
| // Play audio feedback for recording stop | |
| play_feedback_sound(app, SoundType::Stop); | |
| let binding_id = binding_id.to_string(); // Clone binding_id for the async task | |
| tauri::async_runtime::spawn(async move { | |
| let binding_id_clone = binding_id.clone(); // Clone for the inner async task | |
| eprintln!( | |
| "{} [ACTION] Starting async transcription task for binding: {}", | |
| format_timestamp(), binding_id_clone | |
| ); | |
| // Helper to clear busy flag | |
| let clear_busy_flag = || { | |
| if let Some(toggle_state) = ah.try_state::<ManagedToggleState>() { | |
| if let Ok(mut states) = toggle_state.lock() { | |
| states.busy_states.insert(binding_id.clone(), false); | |
| eprintln!("{} [ACTION] Busy flag cleared for binding: {}", format_timestamp(), binding_id); | |
| } | |
| } | |
| }; | |
| let stop_recording_time = Instant::now(); | |
| if let Some(samples) = rm.stop_recording(&binding_id_clone) { | |
| eprintln!( | |
| "{} [ACTION] Recording stopped and samples retrieved in {:?}, sample count: {}", | |
| format_timestamp(), | |
| stop_recording_time.elapsed(), | |
| samples.len() | |
| ); | |
| let transcription_time = Instant::now(); | |
| // Note: We don't clone samples for history since HistoryManager isn't implemented yet | |
| match tm.transcribe(samples) { | |
| Ok(transcription) => { | |
| eprintln!( | |
| "{} [ACTION] Transcription completed in {:?}: '{}'", | |
| format_timestamp(), | |
| transcription_time.elapsed(), | |
| transcription | |
| ); | |
| if !transcription.is_empty() { | |
| // TODO: Save to history when HistoryManager is implemented | |
| // let hm = Arc::clone(&app.state::<Arc<HistoryManager>>()); | |
| // if let Err(e) = hm.save_transcription(samples_clone, transcription_for_history).await { | |
| // error!("Failed to save transcription to history: {}", e); | |
| // } | |
| let transcription_clone = transcription.clone(); | |
| let ah_clone = ah.clone(); | |
| let paste_time = Instant::now(); | |
| ah.run_on_main_thread(move || { | |
| match utils::paste(transcription_clone, ah_clone.clone()) { | |
| Ok(()) => debug!( | |
| "Text pasted successfully in {:?}", | |
| paste_time.elapsed() | |
| ), | |
| Err(e) => eprintln!("Failed to paste transcription: {}", e), | |
| } | |
| // Hide the overlay after transcription is complete | |
| utils::hide_recording_overlay(&ah_clone); | |
| change_tray_icon(&ah_clone, TrayIconState::Idle); | |
| }) | |
| .unwrap_or_else(|e| { | |
| eprintln!("Failed to run paste on main thread: {:?}", e); | |
| utils::hide_recording_overlay(&ah); | |
| change_tray_icon(&ah, TrayIconState::Idle); | |
| }); | |
| } else { | |
| utils::hide_recording_overlay(&ah); | |
| change_tray_icon(&ah, TrayIconState::Idle); | |
| } | |
| } | |
| Err(err) => { | |
| debug!("Global Shortcut Transcription error: {}", err); | |
| utils::hide_recording_overlay(&ah); | |
| change_tray_icon(&ah, TrayIconState::Idle); | |
| } | |
| } | |
| } else { | |
| debug!("No samples retrieved from recording stop"); | |
| utils::hide_recording_overlay(&ah); | |
| change_tray_icon(&ah, TrayIconState::Idle); | |
| } | |
| // Clear busy flag after all operations complete | |
| clear_busy_flag(); | |
| }); | |
| debug!( | |
| "TranscribeAction::stop completed in {:?}", | |
| stop_time.elapsed() | |
| ); | |
| } | |
| } | |
| // Test Action | |
| struct TestAction; | |
| impl ShortcutAction for TestAction { | |
| fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) { | |
| println!( | |
| "Shortcut ID '{}': Started - {} (App: {})", | |
| binding_id, | |
| shortcut_str, | |
| app.package_info().name | |
| ); | |
| } | |
| fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) { | |
| println!( | |
| "Shortcut ID '{}': Stopped - {} (App: {})", | |
| binding_id, | |
| shortcut_str, | |
| app.package_info().name | |
| ); | |
| } | |
| } | |
| // Static Action Map | |
| pub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::new(|| { | |
| let mut map = HashMap::new(); | |
| map.insert( | |
| "transcribe".to_string(), | |
| Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>, | |
| ); | |
| map.insert( | |
| "test".to_string(), | |
| Arc::new(TestAction) as Arc<dyn ShortcutAction>, | |
| ); | |
| map | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! Hyprland native shortcuts implementation using hyprctl | |
| //! | |
| //! This module provides global shortcut functionality specifically for Hyprland | |
| //! by directly managing keybinds via the `hyprctl` command. This works around | |
| //! limitations in xdg-desktop-portal-hyprland's GlobalShortcuts implementation. | |
| use anyhow::{Context, Result}; | |
| use std::collections::HashMap; | |
| use std::path::PathBuf; | |
| use std::process::Command; | |
| use std::sync::Arc; | |
| use tauri::AppHandle; | |
| use tokio::sync::Mutex; | |
| use crate::settings::ShortcutBinding; | |
| /// Manages Hyprland shortcuts via hyprctl keyword bind | |
| pub struct HyprlandShortcutManager { | |
| app: AppHandle, | |
| shortcuts: Arc<Mutex<HashMap<String, ShortcutBinding>>>, | |
| app_path: PathBuf, | |
| } | |
| impl HyprlandShortcutManager { | |
| /// Create a new Hyprland shortcut manager | |
| pub fn new(app: AppHandle) -> Result<Self> { | |
| // Get the path to the current executable | |
| let app_path = std::env::current_exe() | |
| .context("Failed to get current executable path")?; | |
| Ok(Self { | |
| app, | |
| shortcuts: Arc::new(Mutex::new(HashMap::new())), | |
| app_path, | |
| }) | |
| } | |
| /// Check if Hyprland is running | |
| pub fn is_hyprland_running() -> bool { | |
| std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() | |
| } | |
| /// Convert shortcut string to hyprctl format | |
| /// | |
| /// Our format: "ctrl+shift+g" | |
| /// hyprctl format: "CTRL SHIFT, G" (space-separated modifiers, comma before key) | |
| fn convert_to_hyprctl_format(shortcut: &str) -> Result<String> { | |
| eprintln!(" [convert_to_hyprctl_format] Input: '{}'", shortcut); | |
| let parts: Vec<&str> = shortcut.split('+').collect(); | |
| eprintln!(" [convert_to_hyprctl_format] Parts: {:?}", parts); | |
| if parts.is_empty() { | |
| return Err(anyhow::anyhow!("Empty shortcut string")); | |
| } | |
| let mut modifiers = Vec::new(); | |
| let mut key_str = String::new(); | |
| for (idx, part) in parts.iter().enumerate() { | |
| let part_lower = part.to_lowercase(); | |
| eprintln!(" [convert_to_hyprctl_format] Processing part {} of {}: '{}' (lowercase: '{}')", | |
| idx, parts.len() - 1, part, part_lower); | |
| if idx == parts.len() - 1 { | |
| // Last part is the key | |
| key_str = match part_lower.as_str() { | |
| "space" => "space".to_string(), | |
| "return" | "enter" => "return".to_string(), | |
| "tab" => "tab".to_string(), | |
| "backspace" => "backspace".to_string(), | |
| "escape" | "esc" => "escape".to_string(), | |
| other => other.to_string(), | |
| }; | |
| eprintln!(" [convert_to_hyprctl_format] Key identified: '{}'", key_str); | |
| } else { | |
| // Earlier parts are modifiers | |
| let modifier = match part_lower.as_str() { | |
| "ctrl" | "control" => "CTRL", | |
| "alt" => "ALT", | |
| "shift" => "SHIFT", | |
| "super" | "meta" | "win" | "cmd" => "SUPER", | |
| other => return Err(anyhow::anyhow!("Unknown modifier: {}", other)), | |
| }; | |
| eprintln!(" [convert_to_hyprctl_format] Modifier identified: '{}'", modifier); | |
| modifiers.push(modifier); | |
| } | |
| } | |
| if key_str.is_empty() { | |
| return Err(anyhow::anyhow!("No key specified")); | |
| } | |
| let result = if modifiers.is_empty() { | |
| // Just a key, no modifiers | |
| format!(", {}", key_str) | |
| } else { | |
| // Modifiers + key | |
| format!("{}, {}", modifiers.join(" "), key_str) | |
| }; | |
| eprintln!(" [convert_to_hyprctl_format] Output: '{}'", result); | |
| Ok(result) | |
| } | |
| /// Execute hyprctl command | |
| fn exec_hyprctl(args: &[&str]) -> Result<String> { | |
| let output = Command::new("hyprctl") | |
| .args(args) | |
| .output() | |
| .context("Failed to execute hyprctl")?; | |
| if !output.status.success() { | |
| let stderr = String::from_utf8_lossy(&output.stderr); | |
| return Err(anyhow::anyhow!("hyprctl failed: {}", stderr)); | |
| } | |
| Ok(String::from_utf8_lossy(&output.stdout).to_string()) | |
| } | |
| /// Bind a shortcut in Hyprland | |
| /// | |
| /// Binds both press and release events to support PTT mode: | |
| /// - bindl (locked bind on press) -> triggers action start | |
| /// - bindr (bind on release) -> triggers action stop | |
| pub async fn bind_shortcut( | |
| &self, | |
| id: &str, | |
| trigger: &str, | |
| description: &str, | |
| ) -> Result<()> { | |
| eprintln!("=== Binding Hyprland Shortcut ==="); | |
| eprintln!(" ID: {}", id); | |
| eprintln!(" Trigger: {}", trigger); | |
| eprintln!(" Description: {}", description); | |
| // Convert trigger format | |
| let hyprctl_format = Self::convert_to_hyprctl_format(trigger)?; | |
| eprintln!(" hyprctl format: '{}'", hyprctl_format); | |
| // Build the exec commands | |
| let app_path_str = self.app_path.to_str() | |
| .ok_or_else(|| anyhow::anyhow!("Invalid app path"))?; | |
| // Press event (start action) | |
| let exec_cmd_press = format!("{} --trigger-press {}", app_path_str, id); | |
| eprintln!(" Exec command (press): {}", exec_cmd_press); | |
| // Release event (stop action) | |
| let exec_cmd_release = format!("{} --trigger-release {}", app_path_str, id); | |
| eprintln!(" Exec command (release): {}", exec_cmd_release); | |
| // Bind press event (bindl = locked bind, triggers on press even if locked) | |
| let bind_press_cmd = format!("bindl = {}, exec, {}", hyprctl_format, exec_cmd_press); | |
| eprintln!(" Full bind command (press): hyprctl keyword {}", bind_press_cmd); | |
| let output_press = Self::exec_hyprctl(&["keyword", &bind_press_cmd])?; | |
| eprintln!(" hyprctl output (press): {}", output_press.trim()); | |
| // Bind release event | |
| let bind_release_cmd = format!("bindr = {}, exec, {}", hyprctl_format, exec_cmd_release); | |
| eprintln!(" Full bind command (release): hyprctl keyword {}", bind_release_cmd); | |
| let output_release = Self::exec_hyprctl(&["keyword", &bind_release_cmd])?; | |
| eprintln!(" hyprctl output (release): {}", output_release.trim()); | |
| eprintln!("β Shortcut bound successfully (press + release)"); | |
| Ok(()) | |
| } | |
| /// Unbind a shortcut in Hyprland | |
| /// | |
| /// Unbinds both press (bindl) and release (bindr) events | |
| pub async fn unbind_shortcut(&self, trigger: &str) -> Result<()> { | |
| eprintln!("=== Unbinding Hyprland Shortcut ==="); | |
| eprintln!(" Trigger: {}", trigger); | |
| // Convert trigger format | |
| let hyprctl_format = Self::convert_to_hyprctl_format(trigger)?; | |
| eprintln!(" hyprctl format: '{}'", hyprctl_format); | |
| // Unbind press event (bindl) | |
| let unbind_press_cmd = format!("unbindl = {}", hyprctl_format); | |
| eprintln!(" Full unbind command (press): hyprctl keyword {}", unbind_press_cmd); | |
| let output_press = Self::exec_hyprctl(&["keyword", &unbind_press_cmd])?; | |
| eprintln!(" hyprctl output (press): {}", output_press.trim()); | |
| // Unbind release event (bindr) | |
| let unbind_release_cmd = format!("unbindr = {}", hyprctl_format); | |
| eprintln!(" Full unbind command (release): hyprctl keyword {}", unbind_release_cmd); | |
| let output_release = Self::exec_hyprctl(&["keyword", &unbind_release_cmd])?; | |
| eprintln!(" hyprctl output (release): {}", output_release.trim()); | |
| eprintln!("β Shortcut unbound successfully (press + release)"); | |
| Ok(()) | |
| } | |
| /// Initialize all shortcuts from settings | |
| pub async fn init_shortcuts(&self) -> Result<()> { | |
| eprintln!("=== Hyprland Shortcut Initialization ==="); | |
| // Load shortcuts from settings | |
| let settings = crate::settings::load_or_create_app_settings(&self.app); | |
| let bindings: Vec<(String, ShortcutBinding)> = settings.bindings.into_iter().collect(); | |
| eprintln!("β Loaded {} shortcut bindings from settings", bindings.len()); | |
| // Store shortcuts | |
| let mut shortcuts_map = self.shortcuts.lock().await; | |
| for (id, binding) in bindings.iter() { | |
| eprintln!(" Binding: {} -> {}", id, binding.current_binding); | |
| shortcuts_map.insert(id.clone(), binding.clone()); | |
| } | |
| drop(shortcuts_map); | |
| // Bind each shortcut | |
| for (id, binding) in bindings { | |
| if let Err(e) = self.bind_shortcut(&id, &binding.current_binding, &binding.description).await { | |
| eprintln!("β Failed to bind shortcut '{}': {}", id, e); | |
| eprintln!(" Continuing with remaining shortcuts..."); | |
| } | |
| } | |
| eprintln!("=== Hyprland Shortcut Initialization Complete ==="); | |
| Ok(()) | |
| } | |
| /// Update a shortcut binding | |
| pub async fn update_binding( | |
| &self, | |
| id: &str, | |
| old_trigger: &str, | |
| new_trigger: &str, | |
| description: &str, | |
| ) -> Result<()> { | |
| eprintln!("=== Updating Hyprland Shortcut ==="); | |
| eprintln!(" ID: {}", id); | |
| eprintln!(" Old trigger: {}", old_trigger); | |
| eprintln!(" New trigger: {}", new_trigger); | |
| // Unbind old shortcut | |
| if let Err(e) = self.unbind_shortcut(old_trigger).await { | |
| eprintln!(" Warning: Failed to unbind old shortcut: {}", e); | |
| eprintln!(" This might be OK if it wasn't bound yet"); | |
| } | |
| // Bind new shortcut | |
| self.bind_shortcut(id, new_trigger, description).await?; | |
| // Update local storage | |
| let mut shortcuts_lock = self.shortcuts.lock().await; | |
| if let Some(binding) = shortcuts_lock.get_mut(id) { | |
| binding.current_binding = new_trigger.to_string(); | |
| eprintln!("β Local binding updated"); | |
| } | |
| eprintln!("=== Shortcut Update Complete ==="); | |
| Ok(()) | |
| } | |
| /// Cleanup: unbind all shortcuts | |
| pub async fn cleanup(&self) -> Result<()> { | |
| eprintln!("=== Cleaning up Hyprland shortcuts ==="); | |
| let shortcuts_lock = self.shortcuts.lock().await; | |
| for (id, binding) in shortcuts_lock.iter() { | |
| eprintln!(" Unbinding: {}", id); | |
| if let Err(e) = self.unbind_shortcut(&binding.current_binding).await { | |
| eprintln!(" Warning: Failed to unbind '{}': {}", id, e); | |
| } | |
| } | |
| eprintln!("β Cleanup complete"); | |
| Ok(()) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| mod actions; | |
| mod audio_feedback; | |
| pub mod audio_toolkit; | |
| mod clipboard; | |
| mod commands; | |
| mod managers; | |
| mod overlay; | |
| mod settings; | |
| mod shortcut; | |
| mod shortcut_protection; | |
| mod time_utils; | |
| mod tray; | |
| mod utils; | |
| #[cfg(target_os = "linux")] | |
| mod wayland_shortcut; | |
| #[cfg(target_os = "linux")] | |
| mod hyprland_shortcut; | |
| use managers::audio::AudioRecordingManager; | |
| use managers::model::ModelManager; | |
| use managers::transcription::TranscriptionManager; | |
| use std::sync::Arc; | |
| use tauri::image::Image; | |
| use tauri::tray::TrayIconBuilder; | |
| use tauri::Emitter; | |
| use tauri::{AppHandle, Manager}; | |
| use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; | |
| // Re-export state types for use in other modules | |
| pub use shortcut_protection::{ManagedDebounceState, ManagedToggleState}; | |
| use shortcut_protection::{ | |
| check_event_allowed, log_event_check_result, EventCheckResult, ShortcutToggleStates, | |
| TriggerDebounceState, | |
| }; | |
| use std::sync::Mutex; | |
| use time_utils::format_timestamp; | |
| fn show_main_window(app: &AppHandle) { | |
| if let Some(main_window) = app.get_webview_window("main") { | |
| // First, ensure the window is visible | |
| if let Err(e) = main_window.show() { | |
| eprintln!("Failed to show window: {}", e); | |
| } | |
| // Then, bring it to the front and give it focus | |
| if let Err(e) = main_window.set_focus() { | |
| eprintln!("Failed to focus window: {}", e); | |
| } | |
| // Optional: On macOS, ensure the app becomes active if it was an accessory | |
| #[cfg(target_os = "macos")] | |
| { | |
| if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) { | |
| eprintln!("Failed to set activation policy to Regular: {}", e); | |
| } | |
| } | |
| } else { | |
| eprintln!("Main window not found."); | |
| } | |
| } | |
| fn initialize_core_logic(app_handle: &AppHandle) { | |
| // First, initialize the managers | |
| let recording_manager = Arc::new( | |
| AudioRecordingManager::new(app_handle).expect("Failed to initialize recording manager"), | |
| ); | |
| let model_manager = | |
| Arc::new(ModelManager::new(app_handle, None).expect("Failed to initialize model manager")); | |
| let transcription_manager = Arc::new( | |
| TranscriptionManager::new(app_handle, model_manager.clone()) | |
| .expect("Failed to initialize transcription manager"), | |
| ); | |
| // Add managers to Tauri's managed state | |
| app_handle.manage(recording_manager.clone()); | |
| app_handle.manage(model_manager.clone()); | |
| app_handle.manage(transcription_manager.clone()); | |
| // Initialize the shortcuts | |
| shortcut::init_shortcuts(app_handle); | |
| // Apply macOS Accessory policy if starting hidden | |
| #[cfg(target_os = "macos")] | |
| { | |
| let settings = settings::get_settings(app_handle); | |
| if settings.start_hidden { | |
| let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory); | |
| } | |
| } | |
| // Get the current theme to set the appropriate initial icon | |
| let initial_theme = tray::get_current_theme(app_handle); | |
| // Choose the appropriate initial icon based on theme | |
| let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle); | |
| let tray = TrayIconBuilder::new() | |
| .icon( | |
| Image::from_path( | |
| app_handle | |
| .path() | |
| .resolve(initial_icon_path, tauri::path::BaseDirectory::Resource) | |
| .unwrap(), | |
| ) | |
| .unwrap(), | |
| ) | |
| .show_menu_on_left_click(true) | |
| .icon_as_template(true) | |
| .on_menu_event(|app, event| match event.id.as_ref() { | |
| "settings" => { | |
| show_main_window(app); | |
| } | |
| "check_updates" => { | |
| show_main_window(app); | |
| let _ = app.emit("check-for-updates", ()); | |
| } | |
| "cancel" => { | |
| use crate::utils::cancel_current_operation; | |
| // Use centralized cancellation that handles all operations | |
| cancel_current_operation(app); | |
| } | |
| "quit" => { | |
| app.exit(0); | |
| } | |
| _ => {} | |
| }) | |
| .build(app_handle) | |
| .unwrap(); | |
| app_handle.manage(tray); | |
| // Initialize tray menu with idle state | |
| utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle); | |
| // Get the autostart manager and configure based on user setting | |
| let autostart_manager = app_handle.autolaunch(); | |
| let settings = settings::get_settings(&app_handle); | |
| if settings.autostart_enabled { | |
| // Enable autostart if user has opted in | |
| let _ = autostart_manager.enable(); | |
| } else { | |
| // Disable autostart if user has opted out | |
| let _ = autostart_manager.disable(); | |
| } | |
| // Create the recording overlay window (hidden by default) | |
| utils::create_recording_overlay(app_handle); | |
| } | |
| #[tauri::command] | |
| fn trigger_update_check(app: AppHandle) -> Result<(), String> { | |
| app.emit("check-for-updates", ()) | |
| .map_err(|e| e.to_string())?; | |
| Ok(()) | |
| } | |
| #[cfg_attr(mobile, tauri::mobile_entry_point)] | |
| pub fn run() { | |
| env_logger::init(); | |
| tauri::Builder::default() | |
| .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { | |
| eprintln!("{} === Single Instance Callback ===", format_timestamp()); | |
| eprintln!("{} Args received: {:?}", format_timestamp(), args); | |
| eprintln!("{} Args length: {}", format_timestamp(), args.len()); | |
| // Check if this is a trigger command (press or release) | |
| // Note: args[0] is the executable path, so we check args[1] for the flag | |
| if args.len() >= 3 { | |
| let is_press = args[1] == "--trigger-press"; | |
| let is_release = args[1] == "--trigger-release"; | |
| if is_press || is_release { | |
| let trigger_id = &args[2]; | |
| let event_type = if is_press { "PRESS" } else { "RELEASE" }; | |
| eprintln!("{} β Detected trigger {} event: '{}'", format_timestamp(), event_type, trigger_id); | |
| // Check debouncing and busy state using centralized protection system | |
| let check_result = check_event_allowed(app, trigger_id, is_press); | |
| match check_result { | |
| EventCheckResult::Allowed => { | |
| // Event is allowed, proceed with action | |
| } | |
| EventCheckResult::Debounced { .. } | EventCheckResult::Busy => { | |
| // Event is blocked, log and return | |
| log_event_check_result(trigger_id, is_press, &check_result); | |
| if matches!(check_result, EventCheckResult::Debounced { .. }) { | |
| eprintln!("{} === Event debounced, ignoring ===\n", format_timestamp()); | |
| } | |
| return; | |
| } | |
| } | |
| // Execute the action associated with this trigger | |
| if let Some(action) = actions::ACTION_MAP.get(trigger_id) { | |
| eprintln!("{} β Found action in ACTION_MAP for '{}'", format_timestamp(), trigger_id); | |
| let settings = settings::get_settings(app); | |
| eprintln!("{} β Loaded settings (PTT mode: {})", format_timestamp(), settings.push_to_talk); | |
| // Get the shortcut binding to pass the correct string | |
| if let Some(binding) = settings.bindings.get(trigger_id) { | |
| eprintln!("{} β Found binding for trigger: '{}'", format_timestamp(), trigger_id); | |
| eprintln!("{} Current binding: {}", format_timestamp(), binding.current_binding); | |
| // Get toggle state manager for setting busy flags | |
| let toggle_state_manager = app.state::<ManagedToggleState>(); | |
| if is_press { | |
| // Press event | |
| if settings.push_to_talk { | |
| // PTT mode: start recording on press | |
| // Don't set busy flag - only release (transcription) needs it | |
| eprintln!("{} β PTT mode: Executing action.start()...", format_timestamp()); | |
| action.start(app, trigger_id, &binding.current_binding); | |
| eprintln!("{} β action.start() called", format_timestamp()); | |
| } else { | |
| // Toggle mode: check current state | |
| eprintln!("{} β Toggle mode: Checking current state...", format_timestamp()); | |
| let mut states = toggle_state_manager | |
| .lock() | |
| .expect("Failed to lock toggle state manager"); | |
| let is_currently_active = states | |
| .active_toggles | |
| .entry(trigger_id.to_string()) | |
| .or_insert(false); | |
| if *is_currently_active { | |
| eprintln!("{} Currently active, stopping...", format_timestamp()); | |
| // Set busy flag before stopping | |
| states.busy_states.insert(trigger_id.to_string(), true); | |
| drop(states); | |
| action.stop(app, trigger_id, &binding.current_binding); | |
| // Update toggle state | |
| let mut states = toggle_state_manager | |
| .lock() | |
| .expect("Failed to lock toggle state manager"); | |
| *states.active_toggles.get_mut(trigger_id).unwrap() = false; | |
| } else { | |
| eprintln!("{} Currently inactive, starting...", format_timestamp()); | |
| // Set busy flag before starting | |
| states.busy_states.insert(trigger_id.to_string(), true); | |
| drop(states); | |
| action.start(app, trigger_id, &binding.current_binding); | |
| // Update toggle state | |
| let mut states = toggle_state_manager | |
| .lock() | |
| .expect("Failed to lock toggle state manager"); | |
| *states.active_toggles.get_mut(trigger_id).unwrap() = true; | |
| } | |
| } | |
| } else { | |
| // Release event | |
| if settings.push_to_talk { | |
| // PTT mode: stop recording on release | |
| eprintln!("{} β PTT mode: Executing action.stop()...", format_timestamp()); | |
| // Set busy flag before stopping to prevent multiple concurrent stops | |
| let mut states = toggle_state_manager | |
| .lock() | |
| .expect("Failed to lock toggle state manager"); | |
| states.busy_states.insert(trigger_id.to_string(), true); | |
| drop(states); | |
| action.stop(app, trigger_id, &binding.current_binding); | |
| eprintln!("{} β action.stop() called", format_timestamp()); | |
| } else { | |
| // Toggle mode: ignore release events | |
| eprintln!("{} β Toggle mode: Ignoring release event", format_timestamp()); | |
| } | |
| } | |
| } else { | |
| eprintln!("{} β Warning: Trigger '{}' not found in bindings", format_timestamp(), trigger_id); | |
| eprintln!("{} Available bindings: {:?}", format_timestamp(), settings.bindings.keys().collect::<Vec<_>>()); | |
| } | |
| } else { | |
| eprintln!("{} β Warning: No action defined for trigger '{}'", format_timestamp(), trigger_id); | |
| eprintln!("{} Available actions: {:?}", format_timestamp(), actions::ACTION_MAP.keys().collect::<Vec<_>>()); | |
| } | |
| } else { | |
| eprintln!("{} β No trigger detected, showing main window", format_timestamp()); | |
| // Default behavior: show window | |
| show_main_window(app); | |
| } | |
| } else { | |
| eprintln!("{} β No args or insufficient args, showing main window", format_timestamp()); | |
| show_main_window(app); | |
| } | |
| eprintln!("{} === Single Instance Callback Complete ===\n", format_timestamp()); | |
| })) | |
| .plugin(tauri_plugin_fs::init()) | |
| .plugin(tauri_plugin_process::init()) | |
| .plugin(tauri_plugin_shell::init()) | |
| .plugin(tauri_plugin_updater::Builder::new().build()) | |
| .plugin(tauri_plugin_os::init()) | |
| .plugin(tauri_plugin_clipboard_manager::init()) | |
| .plugin(tauri_plugin_macos_permissions::init()) | |
| .plugin(tauri_plugin_opener::init()) | |
| .plugin(tauri_plugin_store::Builder::default().build()) | |
| .plugin(tauri_plugin_global_shortcut::Builder::new().build()) | |
| .plugin(tauri_plugin_autostart::init( | |
| MacosLauncher::LaunchAgent, | |
| Some(vec![]), | |
| )) | |
| .manage(Mutex::new(ShortcutToggleStates::default())) | |
| .manage(Mutex::new(TriggerDebounceState::default())) | |
| .setup(move |app| { | |
| let settings = settings::get_settings(&app.handle()); | |
| let app_handle = app.handle().clone(); | |
| initialize_core_logic(&app_handle); | |
| // Show main window only if not starting hidden | |
| if !settings.start_hidden { | |
| if let Some(main_window) = app_handle.get_webview_window("main") { | |
| main_window.show().unwrap(); | |
| main_window.set_focus().unwrap(); | |
| } | |
| } | |
| Ok(()) | |
| }) | |
| .on_window_event(|window, event| match event { | |
| tauri::WindowEvent::CloseRequested { api, .. } => { | |
| api.prevent_close(); | |
| let _res = window.hide(); | |
| #[cfg(target_os = "macos")] | |
| { | |
| let res = window | |
| .app_handle() | |
| .set_activation_policy(tauri::ActivationPolicy::Accessory); | |
| if let Err(e) = res { | |
| println!("Failed to set activation policy: {}", e); | |
| } | |
| } | |
| } | |
| tauri::WindowEvent::ThemeChanged(theme) => { | |
| println!("Theme changed to: {:?}", theme); | |
| // Update tray icon to match new theme, maintaining idle state | |
| utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle); | |
| } | |
| _ => {} | |
| }) | |
| .invoke_handler(tauri::generate_handler![ | |
| shortcut::change_binding, | |
| shortcut::reset_binding, | |
| shortcut::change_ptt_setting, | |
| shortcut::change_audio_feedback_setting, | |
| shortcut::change_audio_feedback_volume_setting, | |
| shortcut::change_sound_theme_setting, | |
| shortcut::change_start_hidden_setting, | |
| shortcut::change_autostart_setting, | |
| shortcut::change_translate_to_english_setting, | |
| shortcut::change_selected_language_setting, | |
| shortcut::change_overlay_position_setting, | |
| shortcut::change_debug_mode_setting, | |
| shortcut::change_word_correction_threshold_setting, | |
| shortcut::change_paste_method_setting, | |
| shortcut::change_clipboard_handling_setting, | |
| shortcut::update_custom_words, | |
| shortcut::suspend_binding, | |
| shortcut::resume_binding, | |
| trigger_update_check, | |
| commands::cancel_operation, | |
| commands::get_app_dir_path, | |
| commands::models::get_available_models, | |
| commands::models::get_model_info, | |
| commands::models::download_model, | |
| commands::models::delete_model, | |
| commands::models::cancel_download, | |
| commands::models::pause_model_download, | |
| commands::models::resume_model_download, | |
| commands::models::cancel_model_download, | |
| commands::models::set_active_model, | |
| commands::models::get_current_model, | |
| commands::models::is_model_loaded, | |
| commands::models::get_transcription_model_status, | |
| commands::models::is_model_loading, | |
| commands::models::has_any_models_available, | |
| commands::models::has_any_models_or_downloads, | |
| commands::models::get_recommended_first_model, | |
| commands::audio::update_microphone_mode, | |
| commands::audio::get_microphone_mode, | |
| commands::audio::get_available_microphones, | |
| commands::audio::set_selected_microphone, | |
| commands::audio::get_selected_microphone, | |
| commands::audio::get_available_output_devices, | |
| commands::audio::set_selected_output_device, | |
| commands::audio::get_selected_output_device, | |
| commands::audio::play_test_sound, | |
| commands::audio::check_custom_sounds, | |
| commands::transcription::set_model_unload_timeout, | |
| commands::transcription::get_model_load_status, | |
| commands::transcription::unload_model_manually, | |
| ]) | |
| .build(tauri::generate_context!()) | |
| .expect("error while building tauri application") | |
| .run(|app_handle, event| { | |
| match event { | |
| tauri::RunEvent::ExitRequested { .. } => { | |
| eprintln!("[App] Exit requested, cleaning up..."); | |
| // Clean up Hyprland shortcuts on Linux | |
| #[cfg(target_os = "linux")] | |
| { | |
| if let Some(manager) = app_handle.try_state::<std::sync::Arc<hyprland_shortcut::HyprlandShortcutManager>>() { | |
| eprintln!("[App] Unbinding Hyprland shortcuts..."); | |
| let manager_clone = manager.inner().clone(); | |
| tauri::async_runtime::block_on(async move { | |
| if let Err(e) = manager_clone.cleanup().await { | |
| eprintln!("[App] Cleanup error: {}", e); | |
| } else { | |
| eprintln!("[App] β Hyprland shortcuts cleaned up"); | |
| } | |
| }); | |
| } | |
| } | |
| // Don't prevent exit, allow app to close after cleanup | |
| } | |
| _ => {} | |
| } | |
| }); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use serde::Serialize; | |
| 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::settings::ShortcutBinding; | |
| use crate::settings::{self, get_settings, ClipboardHandling, OverlayPosition, PasteMethod, SoundTheme}; | |
| use crate::shortcut_protection::{check_event_allowed, log_event_check_result, EventCheckResult}; | |
| use crate::ManagedToggleState; | |
| #[cfg(target_os = "linux")] | |
| use crate::wayland_shortcut::WaylandShortcutManager; | |
| #[cfg(target_os = "linux")] | |
| use crate::hyprland_shortcut::HyprlandShortcutManager; | |
| /// Check if we're running on Wayland (runtime check) | |
| #[cfg(target_os = "linux")] | |
| fn is_using_wayland() -> bool { | |
| is_wayland() | |
| } | |
| /// Detect if we're running on Wayland or X11 | |
| #[cfg(target_os = "linux")] | |
| fn is_wayland() -> bool { | |
| // Check XDG_SESSION_TYPE first (more reliable) | |
| if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") { | |
| if session_type.to_lowercase() == "wayland" { | |
| return true; | |
| } | |
| } | |
| // Fallback: check if WAYLAND_DISPLAY is set | |
| if let Ok(wayland_display) = std::env::var("WAYLAND_DISPLAY") { | |
| if !wayland_display.is_empty() { | |
| return true; | |
| } | |
| } | |
| false | |
| } | |
| /// Check if Hyprland is running | |
| #[cfg(target_os = "linux")] | |
| fn is_hyprland() -> bool { | |
| HyprlandShortcutManager::is_hyprland_running() | |
| } | |
| pub fn init_shortcuts(app: &AppHandle) { | |
| #[cfg(target_os = "linux")] | |
| { | |
| if is_wayland() { | |
| // Check if Hyprland is running | |
| if is_hyprland() { | |
| eprintln!("Detected Hyprland compositor, using native hyprctl bindings"); | |
| // Use Hyprland-specific implementation | |
| match HyprlandShortcutManager::new(app.clone()) { | |
| Ok(hyprland_manager) => { | |
| let hyprland_manager = std::sync::Arc::new(hyprland_manager); | |
| // Store the manager in Tauri state to keep it alive | |
| app.manage(hyprland_manager.clone()); | |
| // Spawn async initialization | |
| let app_clone = app.clone(); | |
| tauri::async_runtime::spawn(async move { | |
| if let Err(e) = hyprland_manager.init_shortcuts().await { | |
| eprintln!("Failed to initialize Hyprland shortcuts: {}", e); | |
| eprintln!("Falling back to X11 compatibility layer..."); | |
| // Fallback to X11 implementation | |
| init_x11_shortcuts(&app_clone); | |
| } | |
| }); | |
| return; | |
| } | |
| Err(e) => { | |
| eprintln!("Failed to create Hyprland manager: {}", e); | |
| eprintln!("Falling back to portal/X11..."); | |
| } | |
| } | |
| } | |
| eprintln!("Detected Wayland session (non-Hyprland), using XDG Desktop Portal for global shortcuts"); | |
| // Use Wayland/XDG Portal implementation | |
| let wayland_manager = std::sync::Arc::new(WaylandShortcutManager::new(app.clone())); | |
| // Store the manager in Tauri state to keep it alive | |
| app.manage(wayland_manager.clone()); | |
| // Spawn async initialization | |
| let app_clone = app.clone(); | |
| tauri::async_runtime::spawn(async move { | |
| if let Err(e) = wayland_manager.init_shortcuts().await { | |
| eprintln!("Failed to initialize Wayland shortcuts: {}", e); | |
| eprintln!("Falling back to X11 compatibility layer..."); | |
| // Fallback to X11 implementation | |
| init_x11_shortcuts(&app_clone); | |
| } | |
| }); | |
| return; | |
| } else { | |
| eprintln!("Detected X11 session, using tauri-plugin-global-shortcut"); | |
| } | |
| } | |
| // Use X11/default implementation for non-Linux or X11 on Linux | |
| init_x11_shortcuts(app); | |
| } | |
| /// Initialize shortcuts using the X11-compatible tauri-plugin-global-shortcut | |
| fn init_x11_shortcuts(app: &AppHandle) { | |
| let settings = settings::load_or_create_app_settings(app); | |
| // Register shortcuts with the bindings from settings | |
| for (_id, binding) in settings.bindings { | |
| if let Err(e) = _register_shortcut(app, binding) { | |
| eprintln!("Failed to register shortcut {} during init: {}", _id, e); | |
| } | |
| } | |
| } | |
| #[derive(Serialize)] | |
| pub struct BindingResponse { | |
| success: bool, | |
| binding: Option<ShortcutBinding>, | |
| error: Option<String>, | |
| } | |
| #[tauri::command] | |
| pub fn change_binding( | |
| app: AppHandle, | |
| id: String, | |
| binding: String, | |
| ) -> Result<BindingResponse, String> { | |
| eprintln!("=== change_binding called ==="); | |
| eprintln!(" ID: {}", id); | |
| eprintln!(" New binding: '{}'", binding); | |
| 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); | |
| eprintln!("change_binding error: {}", error_msg); | |
| return Ok(BindingResponse { | |
| success: false, | |
| binding: None, | |
| error: Some(error_msg), | |
| }); | |
| } | |
| }; | |
| // Validate the new shortcut before we touch the current registration | |
| if let Err(e) = validate_shortcut_string(&binding) { | |
| eprintln!("change_binding validation error: {}", e); | |
| return Err(e); | |
| } | |
| // Create an updated binding | |
| let mut updated_binding = binding_to_modify.clone(); | |
| updated_binding.current_binding = binding.clone(); | |
| // Update the shortcut based on the backend | |
| #[cfg(target_os = "linux")] | |
| { | |
| if is_using_wayland() { | |
| // Check if Hyprland manager is available first | |
| if let Some(manager) = app.try_state::<std::sync::Arc<HyprlandShortcutManager>>() { | |
| eprintln!("Updating Hyprland shortcut via hyprctl..."); | |
| // Spawn async task to update the binding | |
| let manager_clone = manager.inner().clone(); | |
| let id_clone = id.clone(); | |
| let old_binding = binding_to_modify.current_binding.clone(); | |
| let new_binding = binding.clone(); | |
| let description = updated_binding.description.clone(); | |
| eprintln!(" Old binding: '{}'", old_binding); | |
| eprintln!(" New binding: '{}'", new_binding); | |
| eprintln!(" Description: '{}'", description); | |
| tauri::async_runtime::spawn(async move { | |
| eprintln!("β Calling update_binding async..."); | |
| match manager_clone.update_binding(&id_clone, &old_binding, &new_binding, &description).await { | |
| Ok(_) => { | |
| eprintln!("β Hyprland shortcut updated successfully"); | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to update Hyprland shortcut: {}", e); | |
| } | |
| } | |
| }); | |
| } else if let Some(manager) = app.try_state::<std::sync::Arc<WaylandShortcutManager>>() { | |
| eprintln!("Updating Wayland shortcut via portal..."); | |
| // Spawn async task to update the binding | |
| let manager_clone = manager.inner().clone(); | |
| let id_clone = id.clone(); | |
| let binding_clone = binding.clone(); | |
| let description = updated_binding.description.clone(); | |
| tauri::async_runtime::spawn(async move { | |
| match manager_clone.update_binding(&id_clone, &binding_clone, &description).await { | |
| Ok(_) => { | |
| eprintln!("β Wayland shortcut updated successfully"); | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to update Wayland shortcut: {}", e); | |
| } | |
| } | |
| }); | |
| } else { | |
| eprintln!("Warning: No Wayland/Hyprland manager found in state, falling back to X11"); | |
| // Fallback to X11 method | |
| if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) { | |
| eprintln!("Failed to unregister shortcut: {}", e); | |
| } | |
| if let Err(e) = _register_shortcut(&app, updated_binding.clone()) { | |
| let error_msg = format!("Failed to register shortcut: {}", e); | |
| return Ok(BindingResponse { | |
| success: false, | |
| binding: None, | |
| error: Some(error_msg), | |
| }); | |
| } | |
| } | |
| } else { | |
| // X11 path | |
| if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) { | |
| eprintln!("Failed to unregister shortcut: {}", e); | |
| } | |
| if let Err(e) = _register_shortcut(&app, updated_binding.clone()) { | |
| let error_msg = format!("Failed to register shortcut: {}", e); | |
| return Ok(BindingResponse { | |
| success: false, | |
| binding: None, | |
| error: Some(error_msg), | |
| }); | |
| } | |
| } | |
| } | |
| #[cfg(not(target_os = "linux"))] | |
| { | |
| // Non-Linux platforms use X11-style registration | |
| if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) { | |
| eprintln!("Failed to unregister shortcut: {}", e); | |
| } | |
| if let Err(e) = _register_shortcut(&app, updated_binding.clone()) { | |
| let error_msg = format!("Failed to register shortcut: {}", e); | |
| 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] | |
| pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> { | |
| let binding = settings::get_stored_binding(&app, &id); | |
| return change_binding(app, id, binding.default_binding); | |
| } | |
| #[tauri::command] | |
| 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] | |
| 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] | |
| 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] | |
| 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 => { | |
| eprintln!("Invalid sound theme '{}', defaulting to marimba", other); | |
| SoundTheme::Marimba | |
| } | |
| }; | |
| settings.sound_theme = parsed; | |
| settings::write_settings(&app, settings); | |
| Ok(()) | |
| } | |
| #[tauri::command] | |
| 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] | |
| 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] | |
| 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 => { | |
| eprintln!("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] | |
| 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] | |
| 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] | |
| 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] | |
| pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> { | |
| let mut settings = settings::get_settings(&app); | |
| settings.custom_words = words; | |
| settings::write_settings(&app, settings); | |
| Ok(()) | |
| } | |
| #[tauri::command] | |
| 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] | |
| 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, | |
| other => { | |
| eprintln!("Invalid paste method '{}', defaulting to ctrl_v", other); | |
| PasteMethod::CtrlV | |
| } | |
| }; | |
| settings.paste_method = parsed; | |
| settings::write_settings(&app, settings); | |
| Ok(()) | |
| } | |
| #[tauri::command] | |
| 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 => { | |
| eprintln!("Invalid clipboard handling '{}', defaulting to dont_modify", other); | |
| ClipboardHandling::DontModify | |
| } | |
| }; | |
| settings.clipboard_handling = parsed; | |
| settings::write_settings(&app, settings); | |
| Ok(()) | |
| } | |
| /// Determine whether a shortcut string contains at least one non-modifier key. | |
| /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow | |
| /// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). | |
| fn validate_shortcut_string(raw: &str) -> Result<(), String> { | |
| 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 contain at least one non-modifier key".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] | |
| 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) { | |
| eprintln!("suspend_binding error for id '{}': {}", id, e); | |
| return Err(e); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// Re-register the binding after the user has finished editing. | |
| #[tauri::command] | |
| 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) { | |
| eprintln!("resume_binding error for id '{}': {}", id, e); | |
| return Err(e); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { | |
| // Validate human-level rules first | |
| if let Err(e) = validate_shortcut_string(&binding.current_binding) { | |
| eprintln!( | |
| "_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::<Shortcut>() { | |
| Ok(s) => s, | |
| Err(e) => { | |
| let error_msg = format!( | |
| "Failed to parse shortcut '{}': {}", | |
| binding.current_binding, e | |
| ); | |
| eprintln!("_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); | |
| eprintln!("_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 is_press = event.state == ShortcutState::Pressed; | |
| // Check debouncing and busy state using centralized protection system | |
| let check_result = check_event_allowed(ah, &binding_id_for_closure, is_press); | |
| match check_result { | |
| EventCheckResult::Allowed => { | |
| // Event is allowed, proceed with action | |
| } | |
| EventCheckResult::Debounced { .. } | EventCheckResult::Busy => { | |
| // Event is blocked, log and return | |
| log_event_check_result(&binding_id_for_closure, is_press, &check_result); | |
| return; | |
| } | |
| } | |
| let settings = get_settings(ah); | |
| if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) { | |
| 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 { | |
| if event.state == ShortcutState::Pressed { | |
| let toggle_state_manager = ah.state::<ManagedToggleState>(); | |
| 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); | |
| if *is_currently_active { | |
| action.stop( | |
| ah, | |
| &binding_id_for_closure, | |
| &shortcut_string, | |
| ); | |
| *is_currently_active = false; // Update state to inactive | |
| } else { | |
| action.start(ah, &binding_id_for_closure, &shortcut_string); | |
| *is_currently_active = true; // Update state to active | |
| } | |
| } | |
| } | |
| } else { | |
| println!( | |
| "Warning: 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); | |
| eprintln!("_register_shortcut registration error: {}", error_msg); | |
| error_msg | |
| })?; | |
| Ok(()) | |
| } | |
| fn _unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { | |
| let shortcut = match binding.current_binding.parse::<Shortcut>() { | |
| Ok(s) => s, | |
| Err(e) => { | |
| let error_msg = format!( | |
| "Failed to parse shortcut '{}' for unregistration: {}", | |
| binding.current_binding, e | |
| ); | |
| eprintln!("_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 | |
| ); | |
| eprintln!("_unregister_shortcut error: {}", error_msg); | |
| error_msg | |
| })?; | |
| Ok(()) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! Wayland global shortcuts implementation using XDG Desktop Portal. | |
| //! | |
| //! This module provides global shortcut functionality for Wayland compositors | |
| //! (including Hyprland) that support the org.freedesktop.portal.GlobalShortcuts | |
| //! portal interface. | |
| use anyhow::{Context, Result}; | |
| use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut}; | |
| use ashpd::desktop::Session; | |
| use ashpd::WindowIdentifier; | |
| use futures_util::StreamExt; | |
| use std::collections::HashMap; | |
| use std::sync::Arc; | |
| use tauri::{AppHandle, Manager}; | |
| use tokio::sync::Mutex; | |
| use crate::actions::ACTION_MAP; | |
| use crate::settings::{get_settings, ShortcutBinding}; | |
| /// Convert shortcut string from our format to XDG Portal format | |
| /// | |
| /// Our format: "ctrl+shift+g" (lowercase) | |
| /// Portal format: "CTRL+SHIFT+g" (uppercase modifiers, xkbcommon keysyms) | |
| fn convert_to_portal_format(shortcut: &str) -> String { | |
| shortcut | |
| .split('+') | |
| .map(|part| { | |
| let part_lower = part.to_lowercase(); | |
| match part_lower.as_str() { | |
| // Convert modifier names to uppercase as per XDG spec | |
| "ctrl" | "control" => "CTRL".to_string(), | |
| "alt" => "ALT".to_string(), | |
| "shift" => "SHIFT".to_string(), | |
| "super" | "meta" | "win" | "cmd" => "LOGO".to_string(), | |
| "num" => "NUM".to_string(), | |
| // For keys, keep the original case (usually lowercase) | |
| // but handle some special cases | |
| "space" => "space".to_string(), | |
| "return" | "enter" => "Return".to_string(), | |
| "tab" => "Tab".to_string(), | |
| "backspace" => "BackSpace".to_string(), | |
| "escape" | "esc" => "Escape".to_string(), | |
| // Default: keep as-is (handles letter keys like 'g', 'a', etc.) | |
| _ => part.to_string(), | |
| } | |
| }) | |
| .collect::<Vec<_>>() | |
| .join("+") | |
| } | |
| /// Manages Wayland global shortcuts via XDG Desktop Portal | |
| pub struct WaylandShortcutManager { | |
| app: AppHandle, | |
| session: Arc<Mutex<Option<Session<'static, GlobalShortcuts<'static>>>>>, | |
| shortcuts: Arc<Mutex<HashMap<String, ShortcutBinding>>>, | |
| } | |
| impl WaylandShortcutManager { | |
| /// Create a new Wayland shortcut manager | |
| pub fn new(app: AppHandle) -> Self { | |
| Self { | |
| app, | |
| session: Arc::new(Mutex::new(None)), | |
| shortcuts: Arc::new(Mutex::new(HashMap::new())), | |
| } | |
| } | |
| /// Initialize the GlobalShortcuts portal and register all shortcuts from settings | |
| pub async fn init_shortcuts(&self) -> Result<()> { | |
| eprintln!("=== Wayland GlobalShortcuts Initialization ==="); | |
| eprintln!("Step 1: Creating GlobalShortcuts portal proxy..."); | |
| // Create GlobalShortcuts proxy | |
| let global_shortcuts = match GlobalShortcuts::new().await { | |
| Ok(proxy) => { | |
| eprintln!("β GlobalShortcuts proxy created successfully"); | |
| proxy | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to create GlobalShortcuts proxy: {}", e); | |
| return Err(e).context("Failed to create GlobalShortcuts portal proxy"); | |
| } | |
| }; | |
| eprintln!("Step 2: Creating portal session..."); | |
| // Create a session | |
| let session = match global_shortcuts.create_session().await { | |
| Ok(s) => { | |
| eprintln!("β GlobalShortcuts session created successfully"); | |
| s | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to create session: {}", e); | |
| return Err(e).context("Failed to create GlobalShortcuts session"); | |
| } | |
| }; | |
| eprintln!("Step 3: Loading shortcuts from settings..."); | |
| // Load shortcuts from settings | |
| let settings = crate::settings::load_or_create_app_settings(&self.app); | |
| let bindings: Vec<(String, ShortcutBinding)> = settings.bindings.into_iter().collect(); | |
| eprintln!("β Loaded {} shortcut bindings from settings", bindings.len()); | |
| // Prepare shortcuts for binding | |
| let mut new_shortcuts = Vec::new(); | |
| for (id, binding) in bindings.iter() { | |
| let portal_format = convert_to_portal_format(&binding.current_binding); | |
| eprintln!( | |
| " Preparing: {} -> {} ({}) [portal format: {}]", | |
| id, binding.current_binding, binding.description, portal_format | |
| ); | |
| let new_shortcut = NewShortcut::new(id.clone(), binding.description.clone()) | |
| .preferred_trigger(portal_format.as_str()); | |
| new_shortcuts.push(new_shortcut); | |
| } | |
| eprintln!("Step 4: Binding {} shortcuts to portal session...", new_shortcuts.len()); | |
| // Bind shortcuts to the session | |
| let bind_result = match global_shortcuts | |
| .bind_shortcuts(&session, &new_shortcuts, None::<&WindowIdentifier>) | |
| .await { | |
| Ok(result) => { | |
| eprintln!("β Bind request sent successfully"); | |
| result | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to bind shortcuts: {}", e); | |
| return Err(e).context("Failed to bind shortcuts"); | |
| } | |
| }; | |
| eprintln!("Step 5: Getting bind response..."); | |
| let response = match bind_result.response() { | |
| Ok(r) => { | |
| eprintln!("β Bind response received"); | |
| r | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to get bind response: {}", e); | |
| return Err(e).context("Failed to get bind response"); | |
| } | |
| }; | |
| eprintln!("β Successfully bound {} shortcuts", response.shortcuts().len()); | |
| eprintln!("Step 6: Registered shortcuts (from portal response):"); | |
| for shortcut in response.shortcuts() { | |
| let trigger = shortcut.trigger_description(); | |
| eprintln!( | |
| " β ID: '{}' | Description: {} | Trigger: '{}'", | |
| shortcut.id(), | |
| shortcut.description(), | |
| trigger | |
| ); | |
| if trigger.is_empty() { | |
| eprintln!(" β οΈ WARNING: Portal returned EMPTY trigger!"); | |
| eprintln!(" This means the portal accepted but didn't bind the shortcut."); | |
| eprintln!(" Possible causes:"); | |
| eprintln!(" - User confirmation required (check for permission dialog)"); | |
| eprintln!(" - Portal doesn't support BindShortcuts (only ListShortcuts)"); | |
| eprintln!(" - Compositor doesn't implement the feature"); | |
| eprintln!(" - Invalid trigger format still"); | |
| } | |
| } | |
| // CRITICAL DEBUG: Check if portal changed the IDs | |
| eprintln!("Step 6.5: Comparing requested IDs vs portal-returned IDs:"); | |
| for (idx, (requested_id, _)) in bindings.iter().enumerate() { | |
| if let Some(returned_shortcut) = response.shortcuts().get(idx) { | |
| let returned_id = returned_shortcut.id(); | |
| if requested_id != returned_id { | |
| eprintln!(" β οΈ ID MISMATCH!"); | |
| eprintln!(" Requested: '{}'", requested_id); | |
| eprintln!(" Portal returned: '{}'", returned_id); | |
| eprintln!(" This will cause signal lookup to fail!"); | |
| } else { | |
| eprintln!(" β ID match: '{}'", requested_id); | |
| } | |
| } | |
| } | |
| eprintln!("Step 7: Storing shortcuts in manager..."); | |
| // Store the shortcuts | |
| let mut shortcuts_map = self.shortcuts.lock().await; | |
| eprintln!("Storing shortcuts in local map:"); | |
| for (id, binding) in bindings { | |
| eprintln!(" Storing ID: '{}' -> {}", id, binding.current_binding); | |
| shortcuts_map.insert(id, binding); | |
| } | |
| drop(shortcuts_map); | |
| eprintln!("β Shortcuts stored"); | |
| eprintln!("Step 8: Storing session..."); | |
| // Store the session | |
| *self.session.lock().await = Some(session); | |
| eprintln!("β Session stored"); | |
| eprintln!("Step 9: Setting up signal listeners..."); | |
| // Start listening for shortcut activation events | |
| match self.listen_for_activations(global_shortcuts).await { | |
| Ok(_) => { | |
| eprintln!("β Signal listeners started"); | |
| } | |
| Err(e) => { | |
| eprintln!("β Failed to setup listeners: {}", e); | |
| return Err(e); | |
| } | |
| } | |
| eprintln!("=== Wayland GlobalShortcuts Initialization Complete ==="); | |
| Ok(()) | |
| } | |
| /// Listen for shortcut activation/deactivation signals | |
| async fn listen_for_activations(&self, global_shortcuts: GlobalShortcuts<'static>) -> Result<()> { | |
| let app = self.app.clone(); | |
| let shortcuts = self.shortcuts.clone(); | |
| // Get the signal streams before spawning tasks | |
| let activated_stream = global_shortcuts | |
| .receive_activated() | |
| .await | |
| .context("Failed to create activated signal receiver")?; | |
| let deactivated_stream = global_shortcuts | |
| .receive_deactivated() | |
| .await | |
| .context("Failed to create deactivated signal receiver")?; | |
| // Spawn a task to listen for activated signals | |
| tauri::async_runtime::spawn(async move { | |
| let mut stream = activated_stream; | |
| eprintln!("=== SIGNAL LISTENER: Activated stream ready ==="); | |
| eprintln!("Waiting for shortcut activation signals..."); | |
| while let Some(activated) = stream.next().await { | |
| let shortcut_id = activated.shortcut_id(); | |
| let timestamp = activated.timestamp(); | |
| eprintln!(""); // Blank line for visibility | |
| eprintln!("π₯ SIGNAL RECEIVED: Shortcut activated!"); | |
| eprintln!(" Shortcut ID: '{}'", shortcut_id); | |
| eprintln!(" Timestamp: {:?}", timestamp); | |
| // Debug: Show what shortcuts we have stored | |
| let shortcuts_lock = shortcuts.lock().await; | |
| eprintln!(" Available shortcuts in map:"); | |
| for (stored_id, _) in shortcuts_lock.iter() { | |
| eprintln!(" - '{}'", stored_id); | |
| } | |
| // Try to find the binding | |
| if let Some(binding) = shortcuts_lock.get(shortcut_id) { | |
| eprintln!(" β Found matching binding!"); | |
| eprintln!(" Binding ID: {}", binding.id); | |
| eprintln!(" Shortcut: {}", binding.current_binding); | |
| let binding_id = binding.id.clone(); | |
| let shortcut_str = binding.current_binding.clone(); | |
| drop(shortcuts_lock); | |
| // Get the action from the ACTION_MAP | |
| if let Some(action) = ACTION_MAP.get(&binding_id) { | |
| let settings = get_settings(&app); | |
| if settings.push_to_talk { | |
| // PTT mode: start on activation | |
| action.start(&app, &binding_id, &shortcut_str); | |
| } else { | |
| // Toggle mode: check current state | |
| let toggle_state_manager = app.state::<crate::ManagedToggleState>(); | |
| let mut states = toggle_state_manager | |
| .lock() | |
| .expect("Failed to lock toggle state manager"); | |
| let is_currently_active = states | |
| .active_toggles | |
| .entry(binding_id.clone()) | |
| .or_insert(false); | |
| if *is_currently_active { | |
| action.stop(&app, &binding_id, &shortcut_str); | |
| *is_currently_active = false; | |
| } else { | |
| action.start(&app, &binding_id, &shortcut_str); | |
| *is_currently_active = true; | |
| } | |
| } | |
| } else { | |
| eprintln!( | |
| "Warning: No action defined in ACTION_MAP for shortcut ID '{}'", | |
| binding_id | |
| ); | |
| } | |
| } else { | |
| eprintln!(" β NO MATCH FOUND!"); | |
| eprintln!(" Portal sent ID: '{}'", shortcut_id); | |
| eprintln!(" This doesn't match any stored shortcut IDs"); | |
| eprintln!(" Possible ID mismatch or prefix/suffix issue"); | |
| } | |
| } | |
| eprintln!("=== SIGNAL LISTENER: Activated stream ended ==="); | |
| }); | |
| // Spawn a separate task to listen for deactivated signals (for PTT mode) | |
| let app_deactivated = self.app.clone(); | |
| let shortcuts_deactivated = self.shortcuts.clone(); | |
| tauri::async_runtime::spawn(async move { | |
| let mut stream = deactivated_stream; | |
| eprintln!("Listening for GlobalShortcuts deactivated signals..."); | |
| while let Some(deactivated) = stream.next().await { | |
| let shortcut_id = deactivated.shortcut_id(); | |
| let timestamp = deactivated.timestamp(); | |
| eprintln!( | |
| "GlobalShortcut deactivated: {} at timestamp {:?}", | |
| shortcut_id, timestamp | |
| ); | |
| // Get the binding for this shortcut | |
| let shortcuts_lock = shortcuts_deactivated.lock().await; | |
| if let Some(binding) = shortcuts_lock.get(shortcut_id) { | |
| let binding_id = binding.id.clone(); | |
| let shortcut_str = binding.current_binding.clone(); | |
| drop(shortcuts_lock); | |
| // Get the action from the ACTION_MAP | |
| if let Some(action) = ACTION_MAP.get(&binding_id) { | |
| let settings = get_settings(&app_deactivated); | |
| if settings.push_to_talk { | |
| // PTT mode: stop on deactivation | |
| action.stop(&app_deactivated, &binding_id, &shortcut_str); | |
| } | |
| // In toggle mode, deactivation events are ignored | |
| } | |
| } | |
| } | |
| eprintln!("GlobalShortcuts deactivated signal stream ended"); | |
| }); | |
| Ok(()) | |
| } | |
| /// Update a single shortcut binding (rebind with new trigger) | |
| pub async fn update_binding(&self, id: &str, new_trigger: &str, description: &str) -> Result<()> { | |
| eprintln!("=== Updating Wayland Shortcut: {} ===", id); | |
| // Get the session | |
| let session_lock = self.session.lock().await; | |
| let session = match session_lock.as_ref() { | |
| Some(s) => s, | |
| None => { | |
| return Err(anyhow::anyhow!("No active GlobalShortcuts session")); | |
| } | |
| }; | |
| // Create GlobalShortcuts proxy | |
| let global_shortcuts = GlobalShortcuts::new() | |
| .await | |
| .context("Failed to create GlobalShortcuts proxy for update")?; | |
| // Convert trigger to portal format | |
| let portal_format = convert_to_portal_format(new_trigger); | |
| eprintln!("Binding updated shortcut: {} -> {} [portal format: {}]", id, new_trigger, portal_format); | |
| // Create the new shortcut | |
| let new_shortcut = NewShortcut::new(id.to_string(), description.to_string()) | |
| .preferred_trigger(portal_format.as_str()); | |
| // Rebind (this will replace the existing one with the same ID) | |
| let bind_result = global_shortcuts | |
| .bind_shortcuts(session, &[new_shortcut], None::<&WindowIdentifier>) | |
| .await | |
| .context("Failed to rebind shortcut")?; | |
| let response = bind_result | |
| .response() | |
| .context("Failed to get rebind response")?; | |
| eprintln!("β Shortcut rebound successfully"); | |
| for shortcut in response.shortcuts() { | |
| eprintln!( | |
| " Updated: {} -> {} (trigger: {})", | |
| shortcut.id(), | |
| shortcut.description(), | |
| shortcut.trigger_description() | |
| ); | |
| } | |
| // Update local storage | |
| let mut shortcuts_lock = self.shortcuts.lock().await; | |
| if let Some(binding) = shortcuts_lock.get_mut(id) { | |
| binding.current_binding = new_trigger.to_string(); | |
| eprintln!("β Local binding updated"); | |
| } | |
| eprintln!("=== Shortcut Update Complete ==="); | |
| Ok(()) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment