agent: wayland — evdev hotkey backend, download resume, SHA256 integrity
Add kon-hotkey crate with evdev-based global hotkey capture that works on Wayland (and X11). Patterns from whisper-overlay: per-device async listeners, inotify hotplug with udev permission retry, watch channel for live config updates. Frontend detects Wayland at startup and selects evdev or tauri-plugin-global-shortcut automatically. Model downloads now support HTTP Range resume for interrupted downloads and optional SHA256 integrity verification (incremental, no second pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
138
crates/hotkey/src/lib.rs
Normal file
138
crates/hotkey/src/lib.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Wayland-compatible global hotkey listener for Kon.
|
||||
//!
|
||||
//! On Linux, reads `/dev/input/event*` devices via the `evdev` crate to capture
|
||||
//! global hotkeys without any display-server dependency. This works on both X11
|
||||
//! and Wayland, but requires the user to be in the `input` group (or have read
|
||||
//! access to `/dev/input/`).
|
||||
//!
|
||||
//! On non-Linux platforms, this crate is a no-op — the Tauri global-shortcut
|
||||
//! plugin handles hotkeys there.
|
||||
//!
|
||||
//! Architecture stolen from oddlama/whisper-overlay and adapted for Kon.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::*;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod stub;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use stub::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A hotkey combination: one or more modifiers + a trigger key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HotkeyCombo {
|
||||
pub ctrl: bool,
|
||||
pub shift: bool,
|
||||
pub alt: bool,
|
||||
pub super_key: bool,
|
||||
/// The evdev key code for the trigger key (e.g. KEY_R = 19).
|
||||
/// On the frontend, this is mapped from the key name.
|
||||
pub key_code: u16,
|
||||
/// Human-readable label for display (e.g. "Ctrl+Shift+R").
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl HotkeyCombo {
|
||||
/// Parse a Tauri-style hotkey string like "Ctrl+Shift+R" into a HotkeyCombo.
|
||||
/// Returns None if the string can't be parsed.
|
||||
pub fn from_tauri_str(s: &str) -> Option<Self> {
|
||||
let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut ctrl = false;
|
||||
let mut shift = false;
|
||||
let mut alt = false;
|
||||
let mut super_key = false;
|
||||
let mut trigger: Option<&str> = None;
|
||||
|
||||
for part in &parts {
|
||||
match part.to_lowercase().as_str() {
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
"shift" => shift = true,
|
||||
"alt" => alt = true,
|
||||
"super" | "meta" | "cmd" | "command" => super_key = true,
|
||||
_ => trigger = Some(part),
|
||||
}
|
||||
}
|
||||
|
||||
let key_name = trigger?;
|
||||
let key_code = key_name_to_evdev_code(key_name)?;
|
||||
|
||||
Some(Self {
|
||||
ctrl,
|
||||
shift,
|
||||
alt,
|
||||
super_key,
|
||||
key_code,
|
||||
label: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a key name (from the frontend) to an evdev key code.
|
||||
/// Covers the keys likely to be used in hotkey combos.
|
||||
fn key_name_to_evdev_code(name: &str) -> Option<u16> {
|
||||
// evdev key codes from linux/input-event-codes.h
|
||||
Some(match name.to_uppercase().as_str() {
|
||||
"A" => 30, "B" => 48, "C" => 46, "D" => 32, "E" => 18,
|
||||
"F" => 33, "G" => 34, "H" => 35, "I" => 23, "J" => 36,
|
||||
"K" => 37, "L" => 38, "M" => 50, "N" => 49, "O" => 24,
|
||||
"P" => 25, "Q" => 16, "R" => 19, "S" => 31, "T" => 20,
|
||||
"U" => 22, "V" => 47, "W" => 17, "X" => 45, "Y" => 21,
|
||||
"Z" => 44,
|
||||
"1" => 2, "2" => 3, "3" => 4, "4" => 5, "5" => 6,
|
||||
"6" => 7, "7" => 8, "8" => 9, "9" => 10, "0" => 11,
|
||||
"F1" => 59, "F2" => 60, "F3" => 61, "F4" => 62,
|
||||
"F5" => 63, "F6" => 64, "F7" => 65, "F8" => 66,
|
||||
"F9" => 67, "F10" => 68, "F11" => 87, "F12" => 88,
|
||||
"SPACE" | " " => 57,
|
||||
"ESCAPE" | "ESC" => 1,
|
||||
"TAB" => 15,
|
||||
"BACKSPACE" => 14,
|
||||
"ENTER" | "RETURN" => 28,
|
||||
"DELETE" => 111,
|
||||
"HOME" => 102, "END" => 107,
|
||||
"PAGEUP" => 104, "PAGEDOWN" => 109,
|
||||
"UP" | "ARROWUP" => 103,
|
||||
"DOWN" | "ARROWDOWN" => 108,
|
||||
"LEFT" | "ARROWLEFT" => 105,
|
||||
"RIGHT" | "ARROWRIGHT" => 106,
|
||||
"INSERT" => 110,
|
||||
"PAUSE" => 119,
|
||||
"SCROLLLOCK" => 70,
|
||||
"PRINTSCREEN" => 99,
|
||||
"`" | "BACKQUOTE" => 41,
|
||||
"-" | "MINUS" => 12,
|
||||
"=" | "EQUAL" => 13,
|
||||
"[" | "BRACKETLEFT" => 26,
|
||||
"]" | "BRACKETRIGHT" => 27,
|
||||
"\\" | "BACKSLASH" => 43,
|
||||
";" | "SEMICOLON" => 39,
|
||||
"'" | "QUOTE" => 40,
|
||||
"," | "COMMA" => 51,
|
||||
"." | "PERIOD" => 52,
|
||||
"/" | "SLASH" => 53,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether the current user can read evdev devices.
|
||||
/// Returns a diagnostic message if not.
|
||||
pub fn check_evdev_access() -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::check_access()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Err("evdev hotkeys are only supported on Linux".to_string())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user