From 8e70cf9ff99a85f522e2437311d69ce6e867c232 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 3 Apr 2026 17:50:48 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20wayland=20=E2=80=94=20evdev=20hotkey?= =?UTF-8?q?=20backend,=20download=20resume,=20SHA256=20integrity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/core/src/model_registry.rs | 9 + crates/hotkey/Cargo.toml | 16 ++ crates/hotkey/src/lib.rs | 138 +++++++++ crates/hotkey/src/linux.rs | 333 ++++++++++++++++++++++ crates/hotkey/src/stub.rs | 32 +++ crates/transcription/Cargo.toml | 3 + crates/transcription/src/model_manager.rs | 80 +++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/hotkey.rs | 107 +++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 8 + src/routes/+layout.svelte | 94 +++++- 12 files changed, 804 insertions(+), 18 deletions(-) create mode 100644 crates/hotkey/Cargo.toml create mode 100644 crates/hotkey/src/lib.rs create mode 100644 crates/hotkey/src/linux.rs create mode 100644 crates/hotkey/src/stub.rs create mode 100644 src-tauri/src/commands/hotkey.rs diff --git a/crates/core/src/model_registry.rs b/crates/core/src/model_registry.rs index e8ace94..5577965 100644 --- a/crates/core/src/model_registry.rs +++ b/crates/core/src/model_registry.rs @@ -40,6 +40,8 @@ pub struct ModelFile { pub filename: &'static str, pub url: &'static str, pub size: Megabytes, + /// SHA256 hex digest for integrity verification. None to skip check. + pub sha256: Option<&'static str>, } /// All metadata for a single downloadable model. @@ -74,16 +76,19 @@ static ALL_MODELS: LazyLock> = LazyLock::new(|| { filename: "encoder-model.onnx", url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx", size: Megabytes(1), + sha256: None, }, ModelFile { filename: "model_int8.onnx_data", url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx_data", size: Megabytes(611), + sha256: None, }, ModelFile { filename: "tokenizer.json", url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json", size: Megabytes(1), + sha256: None, }, ], description: "Fastest local model — near-instant transcription", @@ -101,6 +106,7 @@ static ALL_MODELS: LazyLock> = LazyLock::new(|| { filename: "ggml-tiny.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin", size: Megabytes(75), + sha256: None, }], description: "Bundled with app — works instantly", }, @@ -117,6 +123,7 @@ static ALL_MODELS: LazyLock> = LazyLock::new(|| { filename: "ggml-base.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin", size: Megabytes(142), + sha256: None, }], description: "Good balance of speed and accuracy", }, @@ -133,6 +140,7 @@ static ALL_MODELS: LazyLock> = LazyLock::new(|| { filename: "ggml-small.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin", size: Megabytes(466), + sha256: None, }], description: "Accuracy-first English transcription", }, @@ -149,6 +157,7 @@ static ALL_MODELS: LazyLock> = LazyLock::new(|| { filename: "ggml-medium.en.bin", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin", size: Megabytes(1500), + sha256: None, }], description: "Best Whisper accuracy — needs 4+ GB RAM", }, diff --git a/crates/hotkey/Cargo.toml b/crates/hotkey/Cargo.toml new file mode 100644 index 0000000..f7b80c6 --- /dev/null +++ b/crates/hotkey/Cargo.toml @@ -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"] } diff --git a/crates/hotkey/src/lib.rs b/crates/hotkey/src/lib.rs new file mode 100644 index 0000000..daee120 --- /dev/null +++ b/crates/hotkey/src/lib.rs @@ -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 { + 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 { + // 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()) + } +} diff --git a/crates/hotkey/src/linux.rs b/crates/hotkey/src/linux.rs new file mode 100644 index 0000000..d46d563 --- /dev/null +++ b/crates/hotkey/src/linux.rs @@ -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>, + /// 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, + ) -> 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::::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::(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| { + 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>, + event_tx: &mpsc::Sender, + tracked: &Arc>>, +) { + 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>, + event_tx: &mpsc::Sender, + tracked: &Arc>>, +) -> 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>, + event_tx: mpsc::Sender, +) -> Result<(), Box> { + 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")) +} diff --git a/crates/hotkey/src/stub.rs b/crates/hotkey/src/stub.rs new file mode 100644 index 0000000..43adb1b --- /dev/null +++ b/crates/hotkey/src/stub.rs @@ -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, + ) -> 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) {} +} diff --git a/crates/transcription/Cargo.toml b/crates/transcription/Cargo.toml index d42e695..84e5c12 100644 --- a/crates/transcription/Cargo.toml +++ b/crates/transcription/Cargo.toml @@ -16,3 +16,6 @@ tokio = { version = "1", features = ["rt", "sync"] } # Model downloads reqwest = { version = "0.12", features = ["stream"] } futures-util = "0.3" + +# Download integrity verification +sha2 = "0.10" diff --git a/crates/transcription/src/model_manager.rs b/crates/transcription/src/model_manager.rs index 3e15c19..5c00aaa 100644 --- a/crates/transcription/src/model_manager.rs +++ b/crates/transcription/src/model_manager.rs @@ -76,6 +76,11 @@ pub async fn download( Ok(()) } +/// Download a single file with HTTP Range resume and optional SHA256 verification. +/// +/// Resume pattern from Buzz (chidiwilliams/buzz): if a .part file exists, +/// send a Range header to resume from where we left off. SHA256 is checked +/// incrementally during download — no second pass over the file. async fn download_file( file: &ModelFile, dest: &Path, @@ -83,6 +88,7 @@ async fn download_file( progress: &(impl Fn(DownloadProgress) + Send), ) -> Result<()> { use futures_util::StreamExt; + use sha2::{Digest, Sha256}; let part_path = dest.with_extension( dest.extension() @@ -95,23 +101,72 @@ async fn download_file( .build() .map_err(|e| KonError::DownloadFailed(e.to_string()))?; - let response = client - .get(file.url) + // Check for existing partial download (resume support) + let existing_bytes = if part_path.exists() { + std::fs::metadata(&part_path) + .map(|m| m.len()) + .unwrap_or(0) + } else { + 0 + }; + + let mut request = client.get(file.url); + + // If we have a partial file and no SHA256 to verify (can't verify partial), + // request a range resume. If SHA256 is set, we restart to ensure integrity. + let resuming = existing_bytes > 0 && file.sha256.is_none(); + if resuming { + request = request.header("Range", format!("bytes={existing_bytes}-")); + } + + let response = request .send() .await .map_err(|e| KonError::DownloadFailed(e.to_string()))?; - let total_bytes = response.content_length().unwrap_or(0); + // Check if server supports range (206 Partial Content) or gave full file (200) + let actually_resuming = resuming && response.status().as_u16() == 206; + + let total_bytes = if actually_resuming { + // Content-Range: bytes START-END/TOTAL — extract TOTAL + response + .headers() + .get("content-range") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.rsplit('/').next()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + } else { + response.content_length().unwrap_or(0) + }; + let mut stream = response.bytes_stream(); - let mut downloaded: u64 = 0; + let mut downloaded: u64 = if actually_resuming { existing_bytes } else { 0 }; let mut last_percent: u8 = 0; - let mut out = std::fs::File::create(&part_path)?; + // Open file for append (resume) or create (fresh start) + let mut out = if actually_resuming { + std::fs::OpenOptions::new() + .append(true) + .open(&part_path)? + } else { + std::fs::File::create(&part_path)? + }; + + // Incremental SHA256 — only when a checksum is provided + let mut hasher = file.sha256.map(|_| Sha256::new()); + + // If resuming without SHA256, we can't hash the already-downloaded portion, + // but we also don't need to — we only hash when sha256 is set, and we + // restart from scratch in that case. while let Some(chunk) = stream.next().await { let chunk = chunk .map_err(|e| KonError::DownloadFailed(e.to_string()))?; std::io::Write::write_all(&mut out, &chunk)?; + if let Some(ref mut h) = hasher { + h.update(&chunk); + } downloaded += chunk.len() as u64; let percent = if total_bytes > 0 { @@ -133,6 +188,21 @@ async fn download_file( } drop(out); + + // Verify SHA256 if provided + if let (Some(expected), Some(hasher)) = (file.sha256, hasher) { + let actual = format!("{:x}", hasher.finalize()); + if actual != expected { + // Delete corrupt file so next attempt starts fresh + let _ = std::fs::remove_file(&part_path); + return Err(KonError::DownloadFailed(format!( + "SHA256 mismatch for {}: expected {}, got {}", + file.filename, expected, actual + ))); + } + } + + // Atomic rename — file is complete and verified std::fs::rename(&part_path, dest)?; Ok(()) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71b30a8..bd4debc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ kon-transcription = { path = "../crates/transcription" } kon-ai-formatting = { path = "../crates/ai-formatting" } kon-storage = { path = "../crates/storage" } kon-cloud-providers = { path = "../crates/cloud-providers" } +kon-hotkey = { path = "../crates/hotkey" } # Tauri tauri = { version = "2", features = ["tray-icon"] } diff --git a/src-tauri/src/commands/hotkey.rs b/src-tauri/src/commands/hotkey.rs new file mode 100644 index 0000000..64d92b3 --- /dev/null +++ b/src-tauri/src/commands/hotkey.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use tauri::Emitter; +use tokio::sync::{mpsc, Mutex}; + +use kon_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent}; + +/// Managed state for the evdev hotkey listener. +pub struct HotkeyState { + listener: Arc>>, +} + +impl HotkeyState { + pub fn new() -> Self { + Self { + listener: Arc::new(Mutex::new(None)), + } + } +} + +/// Check whether the current session is running on Wayland. +#[tauri::command] +pub fn is_wayland_session() -> bool { + std::env::var("WAYLAND_DISPLAY").is_ok() + || std::env::var("XDG_SESSION_TYPE") + .map(|v| v == "wayland") + .unwrap_or(false) +} + +/// Check whether evdev hotkey capture is available (user in `input` group, etc.). +#[tauri::command] +pub fn check_hotkey_access() -> Result<(), String> { + kon_hotkey::check_evdev_access() +} + +/// Start the evdev global hotkey listener. Emits "kon:hotkey-pressed" and +/// "kon:hotkey-released" events to the frontend. +/// +/// If a listener is already running, it is stopped first. +#[tauri::command] +pub async fn start_evdev_hotkey( + app: tauri::AppHandle, + state: tauri::State<'_, HotkeyState>, + hotkey: String, +) -> Result<(), String> { + let combo = HotkeyCombo::from_tauri_str(&hotkey) + .ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?; + + // Stop existing listener if any + let mut guard = state.listener.lock().await; + if let Some(existing) = guard.take() { + existing.stop().await; + } + + let (event_tx, mut event_rx) = mpsc::channel::(64); + let listener = EvdevHotkeyListener::start(combo, event_tx); + + *guard = Some(listener); + drop(guard); + + // Forward evdev events to Tauri event bus + let app_clone = app.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event { + HotkeyEvent::Pressed => { + let _ = app_clone.emit("kon:hotkey-pressed", ()); + } + HotkeyEvent::Released => { + let _ = app_clone.emit("kon:hotkey-released", ()); + } + } + } + }); + + Ok(()) +} + +/// Update the hotkey combo on a running listener. +#[tauri::command] +pub async fn update_evdev_hotkey( + state: tauri::State<'_, HotkeyState>, + hotkey: String, +) -> Result<(), String> { + let combo = HotkeyCombo::from_tauri_str(&hotkey) + .ok_or_else(|| format!("Cannot parse hotkey: {hotkey}"))?; + + let guard = state.listener.lock().await; + if let Some(ref listener) = *guard { + listener.set_hotkey(combo); + Ok(()) + } else { + Err("Hotkey listener not running".to_string()) + } +} + +/// Stop the evdev hotkey listener. +#[tauri::command] +pub async fn stop_evdev_hotkey( + state: tauri::State<'_, HotkeyState>, +) -> Result<(), String> { + let mut guard = state.listener.lock().await; + if let Some(listener) = guard.take() { + listener.stop().await; + } + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d8b9d73..02ac99a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod audio; pub mod clipboard; pub mod hardware; +pub mod hotkey; pub mod models; pub mod transcription; pub mod windows; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index df6af98..f56f9d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -107,6 +107,8 @@ pub fn run() { // Store init script for secondary windows (float, viewer) app.manage(PreferencesScript(init_script)); + app.manage(commands::hotkey::HotkeyState::new()); + app.manage(AppState { whisper_engine: Arc::new(LocalEngine::new( EngineName::new("whisper"), @@ -152,6 +154,12 @@ pub fn run() { // Hardware commands::hardware::probe_system, commands::hardware::rank_models, + // Hotkey (evdev — Wayland-compatible) + commands::hotkey::is_wayland_session, + commands::hotkey::check_hotkey_access, + commands::hotkey::start_evdev_hotkey, + commands::hotkey::update_evdev_hotkey, + commands::hotkey::stop_evdev_hotkey, ]) .run(tauri::generate_context!()) .expect("error while running Kon"); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2550412..9f877fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -41,29 +41,89 @@ } }); - // Global hotkey registration + // Global hotkey registration — dual backend + // Wayland: evdev via kon-hotkey crate (works without display server) + // X11/macOS/Windows: tauri-plugin-global-shortcut (native) let registeredHotkey = null; + let hotkeyBackend = $state("unknown"); // "evdev" | "tauri" | "unavailable" + + async function initHotkeyBackend() { + try { + const isWayland = await invoke("is_wayland_session"); + if (isWayland) { + // Try evdev backend first (Wayland-compatible) + try { + await invoke("check_hotkey_access"); + hotkeyBackend = "evdev"; + console.log("Hotkey backend: evdev (Wayland)"); + } catch (err) { + console.warn("evdev hotkey access denied:", err); + console.warn("Add your user to the 'input' group for global hotkeys on Wayland"); + hotkeyBackend = "unavailable"; + } + } else { + hotkeyBackend = "tauri"; + console.log("Hotkey backend: tauri-plugin-global-shortcut (X11)"); + } + } catch { + // Fallback to tauri plugin if detection fails + hotkeyBackend = "tauri"; + } + } async function registerGlobalHotkey(hotkey) { + if (hotkeyBackend === "unknown") return; // not yet initialised + try { - const mod = await import("@tauri-apps/plugin-global-shortcut"); - if (registeredHotkey) { - await mod.unregister(registeredHotkey).catch(() => {}); - } - await mod.register(hotkey, () => { - if (page.current !== "dictation") page.current = "dictation"; - requestAnimationFrame(() => { - window.dispatchEvent(new CustomEvent("kon:toggle-recording")); + if (hotkeyBackend === "evdev") { + // evdev backend: start or update the Rust-side listener + if (registeredHotkey) { + await invoke("update_evdev_hotkey", { hotkey }); + } else { + await invoke("start_evdev_hotkey", { hotkey }); + } + registeredHotkey = hotkey; + } else if (hotkeyBackend === "tauri") { + // Tauri plugin backend (X11/macOS/Windows) + const mod = await import("@tauri-apps/plugin-global-shortcut"); + if (registeredHotkey) { + await mod.unregister(registeredHotkey).catch(() => {}); + } + await mod.register(hotkey, () => { + if (page.current !== "dictation") page.current = "dictation"; + requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent("kon:toggle-recording")); + }); }); - }); - registeredHotkey = hotkey; + registeredHotkey = hotkey; + } } catch (err) { console.error("Hotkey registration failed:", err); } } + // Listen for evdev hotkey events from the Rust backend + let unlistenEvdev = null; + async function setupEvdevListener() { + const { listen } = await import("@tauri-apps/api/event"); + unlistenEvdev = await listen("kon:hotkey-pressed", () => { + if (page.current !== "dictation") page.current = "dictation"; + requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent("kon:toggle-recording")); + }); + }); + } + $effect(() => { - registerGlobalHotkey(settings.globalHotkey); + if (hotkeyBackend === "evdev" && !unlistenEvdev) { + setupEvdevListener(); + } + }); + + $effect(() => { + if (hotkeyBackend !== "unknown") { + registerGlobalHotkey(settings.globalHotkey); + } }); // Apply font size setting as CSS variable (legacy — kept for backwards compat) @@ -100,6 +160,9 @@ handleResize(); window.addEventListener("resize", handleResize); + // Detect and initialise the correct hotkey backend + await initHotkeyBackend(); + try { const whisper = await invoke("list_models"); const parakeet = await invoke("list_parakeet_models"); @@ -113,11 +176,16 @@ onDestroy(() => { window.removeEventListener("resize", handleResize); - if (registeredHotkey) { + if (hotkeyBackend === "evdev") { + invoke("stop_evdev_hotkey").catch(() => {}); + } else if (hotkeyBackend === "tauri" && registeredHotkey) { import("@tauri-apps/plugin-global-shortcut") .then((mod) => mod.unregister(registeredHotkey)) .catch(() => {}); } + if (unlistenEvdev) { + unlistenEvdev(); + } });