From 4d0cfaa19436e987fa650fce842da56c796c2d2d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 17 Sep 2026 13:40:42 -0400 Subject: [PATCH] feat(native): system-wide PTT/deafen via a non-consuming key poll (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PTT and deafen are DOM key handlers in cinny and only fire while the Lotus window has focus — alt-tab into a fullscreen game and the voice controls stop working. New native/hotkeys.rs (Windows): `set_global_hotkeys(bindings)` starts a thread that samples GetAsyncKeyState for the configured W3C key codes every ~8 ms while a call is joined and emits a `lotus-global-hotkey` DOM event {id, state, ctrl, alt, meta} on each press/release transition; an empty list stops it. Deliberately NOT RegisterHotKey / a global-shortcut plugin: those consume the key system-wide (a bare Space PTT would stop every other app typing spaces). `global_hotkeys_supported` reports false off Windows, where the commands are no-ops (Linux X11 could poll XQueryKeymap later; Wayland has no non-consuming path). HotkeyPoll state is managed unconditionally in setup. Cargo: Win32_UI_Input_KeyboardAndMouse feature. Cross-checked against windows 0.61 for x86_64-pc-windows-msvc; Linux cargo check clean. Web side: cinny 00584d78 (useCallHotkeys + Settings → Calls toggle). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 7 + src-tauri/src/native/hotkeys.rs | 235 ++++++++++++++++++++++++++++++++ src-tauri/src/native/mod.rs | 1 + 4 files changed, 244 insertions(+) create mode 100644 src-tauri/src/native/hotkeys.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26a6ad8..efb1944 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,7 @@ windows = { version = "0.61", features = [ "Win32_System_Com_StructuredStorage", # P5-36 jump list (PROPVARIANT) "Win32_System_Power", # P5-46 no-sleep "Win32_System_WinRT", # P5-43 SMTC interop + "Win32_UI_Input_KeyboardAndMouse", # cinny-desktop #2 global PTT/deafen poll "Win32_UI_Shell", "Win32_UI_Shell_Common", # P5-36 jump list (IObjectArray/IObjectCollection) "Win32_UI_Shell_PropertiesSystem", # P5-36 jump list (IPropertyStore/PKEY_Title) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fdd3d9..d9125b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -519,6 +519,8 @@ pub fn run() { native::chrome::window_start_drag, native::chrome::window_close, native::toast::show_rich_toast, + native::hotkeys::global_hotkeys_supported, + native::hotkeys::set_global_hotkeys, ]) .plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin( @@ -553,6 +555,11 @@ pub fn run() { builder .setup(move |app| { + // cinny-desktop #2: system-wide PTT/deafen poll state (idle until the + // web side registers bindings on call join). Managed unconditionally + // so the commands can never hit a missing-state panic. + app.manage(native::hotkeys::HotkeyPoll::default()); + // --- System tray: keeps Lotus Chat running in the background so // notifications keep arriving after the window is closed-to-tray. --- // Degrade gracefully if the bundled icon is missing rather than diff --git a/src-tauri/src/native/hotkeys.rs b/src-tauri/src/native/hotkeys.rs new file mode 100644 index 0000000..532d2a1 --- /dev/null +++ b/src-tauri/src/native/hotkeys.rs @@ -0,0 +1,235 @@ +//! 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) +} diff --git a/src-tauri/src/native/mod.rs b/src-tauri/src/native/mod.rs index efe1258..795ff00 100644 --- a/src-tauri/src/native/mod.rs +++ b/src-tauri/src/native/mod.rs @@ -11,6 +11,7 @@ use tauri::{AppHandle, Manager}; pub mod aumid; pub mod chrome; pub mod focus_assist; +pub mod hotkeys; pub mod jumplist; pub mod network; pub mod power;