//! System-wide voice hotkeys (cinny-desktop #2). //! //! PTT and deafen are DOM key handlers in cinny and only fire while the Lotus //! window (or the Element Call iframe) has focus — the moment a player alt-tabs //! into a fullscreen game the voice controls stop working. This module keeps //! them working while the window is unfocused. //! //! Design: a **non-consuming poll**, not `RegisterHotKey`/global-shortcut. A //! registered hotkey swallows the key system-wide — a bare `Space` PTT would //! stop every other app from typing spaces, and `M` for deafen would eat the //! letter everywhere. Instead, while a call is joined, a background thread //! samples `GetAsyncKeyState` for the two configured virtual keys every ~8 ms //! and emits a DOM event **only on a press/release transition**. The key still //! reaches the game. The web side ignores these events while the Lotus window //! itself has focus (its DOM handlers own that case, with their editable-field //! and modifier checks), so nothing double-fires. //! //! Windows only. Linux X11 could poll `XQueryKeymap` and Wayland has no //! non-consuming path at all; both are no-ops here and `global_hotkeys_supported` //! reports `false` so the web side hides the toggle. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, State}; /// One binding the web side wants watched: `id` is echoed back in the event /// (`ptt` / `deafen`), `code` is the W3C `KeyboardEvent.code` the user picked in /// Settings (the same value the DOM handlers compare against). #[derive(serde::Deserialize, Clone)] pub struct HotkeyBinding { pub id: String, pub code: String, } /// Managed state: the currently running poll (if any) and its stop flag. #[derive(Default)] pub struct HotkeyPoll { inner: Mutex>, } struct PollHandle { stop: Arc, thread: std::thread::JoinHandle<()>, } fn stop_current(state: &HotkeyPoll) { let handle = state.inner.lock().ok().and_then(|mut guard| guard.take()); if let Some(handle) = handle { handle.stop.store(true, Ordering::Relaxed); let _ = handle.thread.join(); } } /// Whether this platform can watch keys without consuming them. #[tauri::command] pub fn global_hotkeys_supported() -> bool { cfg!(target_os = "windows") } /// Replace the watched set. An empty list stops the poll (call on hangup / /// setting off / quit). Unknown `code`s are skipped, never an error — the DOM /// path still handles them in-focus. #[tauri::command] pub fn set_global_hotkeys( app: AppHandle, state: State<'_, HotkeyPoll>, bindings: Vec, ) -> Result<(), String> { stop_current(&state); #[cfg(target_os = "windows")] { let watched: Vec<(String, u16)> = bindings .into_iter() .filter_map(|b| vk_from_code(&b.code).map(|vk| (b.id, vk))) .collect(); if watched.is_empty() { return Ok(()); } let stop = Arc::new(AtomicBool::new(false)); let thread = { let stop = stop.clone(); let app = app.clone(); std::thread::spawn(move || poll_keys(app, watched, stop)) }; if let Ok(mut guard) = state.inner.lock() { *guard = Some(PollHandle { stop, thread }); } } #[cfg(not(target_os = "windows"))] { let _ = (app, bindings); } Ok(()) } /// Payload for the `lotus-global-hotkey` DOM event. #[cfg(target_os = "windows")] #[derive(serde::Serialize)] struct HotkeyEvent<'a> { id: &'a str, state: &'static str, ctrl: bool, alt: bool, meta: bool, } #[cfg(target_os = "windows")] fn poll_keys(app: AppHandle, watched: Vec<(String, u16)>, stop: Arc) { use std::time::Duration; use windows::Win32::UI::Input::KeyboardAndMouse::{ GetAsyncKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, }; let is_down = |vk: u16| -> bool { // High bit set = currently down. Safe: pure read of key state. (unsafe { GetAsyncKeyState(vk as i32) } as u16) & 0x8000 != 0 }; let mut was_down = vec![false; watched.len()]; while !stop.load(Ordering::Relaxed) { for (i, (id, vk)) in watched.iter().enumerate() { let down = is_down(*vk); if down == was_down[i] { continue; } was_down[i] = down; let payload = HotkeyEvent { id, state: if down { "pressed" } else { "released" }, ctrl: is_down(VK_CONTROL.0), alt: is_down(VK_MENU.0), meta: is_down(VK_LWIN.0) || is_down(VK_RWIN.0), }; if let Ok(json) = serde_json::to_string(&payload) { super::emit_to_web(&app, "lotus-global-hotkey", &json); } } std::thread::sleep(Duration::from_millis(8)); } } /// Map a W3C `KeyboardEvent.code` to a Windows virtual-key code. Covers the /// keys a user can realistically bind in Settings → Calls; anything else is /// `None` and is simply not watched globally. #[cfg(target_os = "windows")] fn vk_from_code(code: &str) -> Option { use windows::Win32::UI::Input::KeyboardAndMouse::*; if let Some(letter) = code.strip_prefix("Key") { let mut chars = letter.chars(); if let (Some(c), None) = (chars.next(), chars.next()) { if c.is_ascii_uppercase() { return Some(c as u16); // VK_A..VK_Z == 'A'..'Z' } } return None; } if let Some(digit) = code.strip_prefix("Digit") { let mut chars = digit.chars(); if let (Some(c), None) = (chars.next(), chars.next()) { if c.is_ascii_digit() { return Some(c as u16); // VK_0..VK_9 == '0'..'9' } } return None; } if let Some(n) = code.strip_prefix("Numpad") { if let Ok(d) = n.parse::() { if d <= 9 { return Some(VK_NUMPAD0.0 + d); } } } if let Some(n) = code.strip_prefix('F') { if let Ok(f) = n.parse::() { if (1..=24).contains(&f) { return Some(VK_F1.0 + (f - 1)); } } } let vk = match code { "Space" => VK_SPACE, "Tab" => VK_TAB, "Enter" | "NumpadEnter" => VK_RETURN, "Backspace" => VK_BACK, "Escape" => VK_ESCAPE, "CapsLock" => VK_CAPITAL, "ShiftLeft" => VK_LSHIFT, "ShiftRight" => VK_RSHIFT, "ControlLeft" => VK_LCONTROL, "ControlRight" => VK_RCONTROL, "AltLeft" => VK_LMENU, "AltRight" => VK_RMENU, "MetaLeft" => VK_LWIN, "MetaRight" => VK_RWIN, "ContextMenu" => VK_APPS, "Insert" => VK_INSERT, "Delete" => VK_DELETE, "Home" => VK_HOME, "End" => VK_END, "PageUp" => VK_PRIOR, "PageDown" => VK_NEXT, "ArrowLeft" => VK_LEFT, "ArrowUp" => VK_UP, "ArrowRight" => VK_RIGHT, "ArrowDown" => VK_DOWN, "NumLock" => VK_NUMLOCK, "ScrollLock" => VK_SCROLL, "Pause" => VK_PAUSE, "PrintScreen" => VK_SNAPSHOT, "NumpadMultiply" => VK_MULTIPLY, "NumpadAdd" => VK_ADD, "NumpadSubtract" => VK_SUBTRACT, "NumpadDecimal" => VK_DECIMAL, "NumpadDivide" => VK_DIVIDE, "Backquote" => VK_OEM_3, "Minus" => VK_OEM_MINUS, "Equal" => VK_OEM_PLUS, "BracketLeft" => VK_OEM_4, "BracketRight" => VK_OEM_6, "Backslash" => VK_OEM_5, "Semicolon" => VK_OEM_1, "Quote" => VK_OEM_7, "Comma" => VK_OEM_COMMA, "Period" => VK_OEM_PERIOD, "Slash" => VK_OEM_2, _ => return None, }; Some(vk.0) }