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
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user