feat(native): system-wide PTT/deafen via a non-consuming key poll (#2)
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-17 13:40:57 -04:00
co-authored by Claude Opus 5
parent a5540b8246
commit 4d0cfaa194
4 changed files with 244 additions and 0 deletions
+1
View File
@@ -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)
+7
View File
@@ -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
+235
View File
@@ -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<Option<PollHandle>>,
}
struct PollHandle {
stop: Arc<AtomicBool>,
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<HotkeyBinding>,
) -> 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<AtomicBool>) {
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<u16> {
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::<u16>() {
if d <= 9 {
return Some(VK_NUMPAD0.0 + d);
}
}
}
if let Some(n) = code.strip_prefix('F') {
if let Ok(f) = n.parse::<u16>() {
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)
}
+1
View File
@@ -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;