use std::sync::Arc; use tauri::Emitter; use tokio::sync::{mpsc, Mutex}; use tokio::task::JoinHandle; use lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}; /// A live evdev listener and the JoinHandle for the forwarder task that /// drains its event channel into Tauri's event bus. They are stored as a /// pair so a reconfigure can join BOTH before installing a replacement — /// the previous implementation leaked the forwarder on every reconfigure /// (atomiser Race-2), causing duplicate Pressed/Released emits to the /// frontend. struct ActiveListener { listener: EvdevHotkeyListener, forwarder: JoinHandle<()>, } /// Managed state for the evdev hotkey listener. pub struct HotkeyState { active: Arc>>, } impl HotkeyState { pub fn new() -> Self { Self { active: Arc::new(Mutex::new(None)), } } } /// Check whether the current session is running on Wayland. #[tauri::command] pub fn is_wayland_session() -> bool { std::env::var("WAYLAND_DISPLAY").is_ok() || std::env::var("XDG_SESSION_TYPE") .map(|v| v == "wayland") .unwrap_or(false) } /// Check whether evdev hotkey capture is available (user in `input` group, etc.). #[tauri::command] pub fn check_hotkey_access() -> Result<(), String> { lumotia_hotkey::check_evdev_access() } /// Tear down an `ActiveListener`. Stops the underlying evdev listener /// first (which drops every internal `event_tx` clone via the supervisor /// shutdown path) and then awaits the forwarder JoinHandle. Bounded by a /// timeout so a stuck forwarder cannot block the caller. async fn shutdown_active(active: ActiveListener) { active.listener.stop().await; // After listener.stop() returns, every device-listener task has // dropped its event_tx clone. The forwarder's mpsc receiver therefore // returns None and the task exits naturally. Bound the wait so a // misbehaving forwarder cannot wedge the caller. match tokio::time::timeout(std::time::Duration::from_secs(2), active.forwarder).await { Ok(Ok(())) => {} Ok(Err(e)) => { tracing::warn!( target: "lumotia_hotkey", error = %e, "hotkey forwarder task panicked" ); } Err(_) => { tracing::warn!( target: "lumotia_hotkey", "hotkey forwarder did not drain within 2s; detaching" ); } } } /// Start the evdev global hotkey listener. Emits "lumotia:hotkey-pressed" and /// "lumotia:hotkey-released" events to the frontend. /// /// If a listener is already running, it is stopped first AND its /// forwarder is joined before the replacement is installed. This is the /// fix for atomiser Race-2: the previous implementation spawned a fresh /// forwarder on every reconfigure without ever joining the old one, so /// every hotkey change leaked a permanent task that received duplicate /// events from the (also-leaked) old device listeners. #[tauri::command] pub async fn start_evdev_hotkey( app: tauri::AppHandle, state: tauri::State<'_, HotkeyState>, hotkey: String, ) -> Result<(), String> { let combo = HotkeyCombo::from_tauri_str(&hotkey) .ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?; // Stop existing listener (and join its forwarder) if any. Done // BEFORE creating the new listener so the old event_tx is fully // dropped and the new event_rx is the only live receiver. let mut guard = state.active.lock().await; if let Some(existing) = guard.take() { // Release the lock for the await so other commands aren't held // up if shutdown_active takes the full 2s timeout. Reacquire // after. drop(guard); shutdown_active(existing).await; guard = state.active.lock().await; } let (event_tx, mut event_rx) = mpsc::channel::(64); let listener = EvdevHotkeyListener::start(combo, event_tx).await; // Forward evdev events to Tauri event bus. The JoinHandle is stored // alongside the listener so the next reconfigure (or explicit stop) // can join it cleanly. let app_clone = app.clone(); let forwarder = tokio::spawn(async move { while let Some(event) = event_rx.recv().await { match event { HotkeyEvent::Pressed => { let _ = app_clone.emit("lumotia:hotkey-pressed", ()); } HotkeyEvent::Released => { let _ = app_clone.emit("lumotia:hotkey-released", ()); } } } tracing::debug!( target: "lumotia_hotkey", "forwarder exiting; all event_tx senders dropped" ); }); *guard = Some(ActiveListener { listener, forwarder, }); Ok(()) } /// Update the hotkey combo on a running listener. #[tauri::command] pub async fn update_evdev_hotkey( state: tauri::State<'_, HotkeyState>, hotkey: String, ) -> Result<(), String> { let combo = HotkeyCombo::from_tauri_str(&hotkey) .ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?; let guard = state.active.lock().await; if let Some(ref active) = *guard { active.listener.set_hotkey(combo); Ok(()) } else { Err("Hotkey listener not running".to_string()) } } /// Stop the evdev hotkey listener. #[tauri::command] pub async fn stop_evdev_hotkey(state: tauri::State<'_, HotkeyState>) -> Result<(), String> { let mut guard = state.active.lock().await; if let Some(active) = guard.take() { drop(guard); shutdown_active(active).await; } Ok(()) }