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:
2026-04-03 17:50:48 +01:00
parent 1933604176
commit 8e70cf9ff9
12 changed files with 804 additions and 18 deletions

16
crates/hotkey/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "kon-hotkey"
version = "0.1.0"
edition = "2021"
description = "Wayland-compatible global hotkey listener for Kon — evdev backend with device hotplug"
[dependencies]
kon-core = { path = "../core" }
tokio = { version = "1", features = ["rt", "sync", "macros", "time"] }
serde = { version = "1", features = ["derive"] }
log = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
evdev = { version = "0.12", features = ["tokio"] }
notify = { version = "7", default-features = false, features = ["macos_fsevent"] }
nix = { version = "0.29", features = ["fs"] }

138
crates/hotkey/src/lib.rs Normal file
View 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())
}
}

333
crates/hotkey/src/linux.rs Normal file
View File

@@ -0,0 +1,333 @@
//! Linux evdev-based global hotkey listener.
//!
//! Reads raw input events from `/dev/input/event*` devices. Works on both
//! X11 and Wayland because it operates at the kernel level, bypassing the
//! display server entirely.
//!
//! Key patterns stolen from oddlama/whisper-overlay:
//! - Device hotplug via `notify` watching `/dev/input/`
//! - Retry loop for udev permission propagation on new devices
//! - Per-device async event streams
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use evdev::{Device, InputEventKind, Key};
use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher};
use tokio::sync::{mpsc, watch, Mutex};
use crate::HotkeyCombo;
/// Events emitted by the hotkey listener.
#[derive(Debug, Clone)]
pub enum HotkeyEvent {
/// The configured hotkey was pressed.
Pressed,
/// The configured hotkey was released (useful for push-to-talk).
Released,
}
/// Manages evdev device listeners and hotplug detection.
pub struct EvdevHotkeyListener {
/// Send a new hotkey config to all listener tasks.
hotkey_tx: watch::Sender<Option<HotkeyCombo>>,
/// Signals all tasks to shut down.
shutdown_tx: mpsc::Sender<()>,
}
impl EvdevHotkeyListener {
/// Start the hotkey listener. Returns the listener handle and a receiver
/// for hotkey events.
///
/// The listener spawns:
/// 1. One async task per input device that has the target key
/// 2. A watcher task that detects new devices via inotify on `/dev/input/`
pub fn start(
combo: HotkeyCombo,
event_tx: mpsc::Sender<HotkeyEvent>,
) -> Self {
let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo));
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let tracked = Arc::new(Mutex::new(HashSet::<PathBuf>::new()));
// Spawn initial device listeners
let hotkey_rx_clone = hotkey_rx.clone();
let event_tx_clone = event_tx.clone();
let tracked_clone = tracked.clone();
tokio::spawn(async move {
scan_and_attach(
&hotkey_rx_clone,
&event_tx_clone,
&tracked_clone,
)
.await;
});
// Spawn hotplug watcher
let hotkey_rx_hotplug = hotkey_rx.clone();
let event_tx_hotplug = event_tx.clone();
let tracked_hotplug = tracked.clone();
tokio::spawn(async move {
let (notify_tx, mut notify_rx) = mpsc::channel::<PathBuf>(32);
// notify watcher runs on a blocking thread internally
let _watcher = {
let notify_tx = notify_tx.clone();
let mut w = recommended_watcher(move |res: Result<notify::Event, _>| {
if let Ok(event) = res {
if matches!(event.kind, EventKind::Create(_)) {
for path in event.paths {
if is_event_device(&path) {
let _ = notify_tx.blocking_send(path);
}
}
}
}
})
.expect("failed to create inotify watcher");
w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive)
.expect("failed to watch /dev/input");
w
};
loop {
tokio::select! {
Some(path) = notify_rx.recv() => {
// Retry opening with backoff — udev permissions propagate
// asynchronously after device creation (whisper-overlay pattern)
let hotkey_rx = hotkey_rx_hotplug.clone();
let event_tx = event_tx_hotplug.clone();
let tracked = tracked_hotplug.clone();
tokio::spawn(async move {
for attempt in 0..5 {
if attempt > 0 {
tokio::time::sleep(
std::time::Duration::from_secs(1)
).await;
}
if try_attach_device(
&path, &hotkey_rx, &event_tx, &tracked,
).await {
break;
}
}
});
}
_ = shutdown_rx.recv() => break,
}
}
});
Self {
hotkey_tx,
shutdown_tx,
}
}
/// Update the hotkey combination. All device listeners pick up the
/// change via the watch channel.
pub fn set_hotkey(&self, combo: HotkeyCombo) {
let _ = self.hotkey_tx.send(Some(combo));
}
/// Stop all listeners and clean up.
pub async fn stop(&self) {
let _ = self.hotkey_tx.send(None);
let _ = self.shutdown_tx.send(()).await;
}
}
/// Check whether the user has access to evdev devices.
pub fn check_access() -> Result<(), String> {
let input_dir = Path::new("/dev/input");
if !input_dir.exists() {
return Err("/dev/input does not exist".to_string());
}
// Try to open any event device
let entries = std::fs::read_dir(input_dir)
.map_err(|e| format!("Cannot read /dev/input: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if is_event_device(&path) {
match Device::open(&path) {
Ok(_) => return Ok(()),
Err(e) => {
if e.kind() == std::io::ErrorKind::PermissionDenied {
return Err(format!(
"Permission denied reading {}. \
Add your user to the 'input' group: \
sudo usermod -aG input $USER \
(then log out and back in)",
path.display()
));
}
}
}
}
}
Err("No input devices found in /dev/input".to_string())
}
/// Scan all `/dev/input/event*` devices and attach listeners to any
/// that support the target key.
async fn scan_and_attach(
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
event_tx: &mpsc::Sender<HotkeyEvent>,
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
) {
let input_dir = Path::new("/dev/input");
let entries = match std::fs::read_dir(input_dir) {
Ok(e) => e,
Err(e) => {
log::error!("Cannot read /dev/input: {e}");
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if is_event_device(&path) {
try_attach_device(&path, hotkey_rx, event_tx, tracked).await;
}
}
}
/// Try to open a device and start listening if it supports the target key.
/// Returns true if the device was successfully attached.
async fn try_attach_device(
path: &Path,
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
event_tx: &mpsc::Sender<HotkeyEvent>,
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
) -> bool {
let mut tracked_set = tracked.lock().await;
if tracked_set.contains(path) {
return true;
}
let device = match Device::open(path) {
Ok(d) => d,
Err(e) => {
log::debug!("Cannot open {}: {e}", path.display());
return false;
}
};
// Check if this device has the keys we need
let supported = device.supported_keys();
let has_keys = supported.map_or(false, |keys| {
// Must support at least some keyboard keys
keys.contains(Key::KEY_A) || keys.contains(Key::KEY_R)
});
if !has_keys {
return false;
}
let device_name = device
.name()
.unwrap_or("unknown")
.to_string();
log::info!("Attached hotkey listener to: {} ({})", device_name, path.display());
tracked_set.insert(path.to_path_buf());
drop(tracked_set);
// Spawn a listener task for this device
let hotkey_rx = hotkey_rx.clone();
let event_tx = event_tx.clone();
let path_owned = path.to_path_buf();
let tracked = tracked.clone();
tokio::spawn(async move {
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
log::warn!("Device listener for {} ended: {e}", path_owned.display());
}
// Remove from tracked set so hotplug can re-attach if reconnected
tracked.lock().await.remove(&path_owned);
});
true
}
/// Listen for events on a single device. Tracks modifier state and fires
/// hotkey events when the combo matches.
async fn device_listener(
device: Device,
mut hotkey_rx: watch::Receiver<Option<HotkeyCombo>>,
event_tx: mpsc::Sender<HotkeyEvent>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut stream = device.into_event_stream()?;
// Track modifier state
let mut ctrl_held = false;
let mut shift_held = false;
let mut alt_held = false;
let mut super_held = false;
loop {
tokio::select! {
result = stream.next_event() => {
let event = result?;
if let InputEventKind::Key(key) = event.kind() {
let pressed = event.value() == 1; // 1 = press, 0 = release, 2 = repeat
let released = event.value() == 0;
// Update modifier state
match key {
Key::KEY_LEFTCTRL | Key::KEY_RIGHTCTRL => {
ctrl_held = pressed || (!released && ctrl_held);
}
Key::KEY_LEFTSHIFT | Key::KEY_RIGHTSHIFT => {
shift_held = pressed || (!released && shift_held);
}
Key::KEY_LEFTALT | Key::KEY_RIGHTALT => {
alt_held = pressed || (!released && alt_held);
}
Key::KEY_LEFTMETA | Key::KEY_RIGHTMETA => {
super_held = pressed || (!released && super_held);
}
trigger_key => {
let combo = hotkey_rx.borrow().clone();
if let Some(ref combo) = combo {
let code = trigger_key.code();
if code == combo.key_code
&& ctrl_held == combo.ctrl
&& shift_held == combo.shift
&& alt_held == combo.alt
&& super_held == combo.super_key
{
if pressed {
let _ = event_tx.send(HotkeyEvent::Pressed).await;
} else if released {
let _ = event_tx.send(HotkeyEvent::Released).await;
}
}
}
}
}
}
}
_ = hotkey_rx.changed() => {
// Hotkey config changed — if set to None, shut down
if hotkey_rx.borrow().is_none() {
break;
}
}
}
}
Ok(())
}
fn is_event_device(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map_or(false, |n| n.starts_with("event"))
}

32
crates/hotkey/src/stub.rs Normal file
View File

@@ -0,0 +1,32 @@
//! No-op stub for non-Linux platforms.
//!
//! On macOS and Windows, Tauri's global-shortcut plugin handles hotkeys
//! natively. This stub exists so the crate compiles on all platforms.
use tokio::sync::mpsc;
use crate::HotkeyCombo;
/// Events emitted by the hotkey listener.
#[derive(Debug, Clone)]
pub enum HotkeyEvent {
Pressed,
Released,
}
/// Stub listener that does nothing on non-Linux platforms.
pub struct EvdevHotkeyListener;
impl EvdevHotkeyListener {
pub fn start(
_combo: HotkeyCombo,
_event_tx: mpsc::Sender<HotkeyEvent>,
) -> Self {
log::info!("evdev hotkey listener is a no-op on this platform");
Self
}
pub fn set_hotkey(&self, _combo: HotkeyCombo) {}
pub async fn stop(&self) {}
}