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:
@@ -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<Vec<ModelEntry>> = 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<Vec<ModelEntry>> = 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<Vec<ModelEntry>> = 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<Vec<ModelEntry>> = 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<Vec<ModelEntry>> = 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",
|
||||
},
|
||||
|
||||
16
crates/hotkey/Cargo.toml
Normal file
16
crates/hotkey/Cargo.toml
Normal 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
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())
|
||||
}
|
||||
}
|
||||
333
crates/hotkey/src/linux.rs
Normal file
333
crates/hotkey/src/linux.rs
Normal 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
32
crates/hotkey/src/stub.rs
Normal 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) {}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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::<u64>().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(())
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
107
src-tauri/src/commands/hotkey.rs
Normal file
107
src-tauri/src/commands/hotkey.rs
Normal file
@@ -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<Mutex<Option<EvdevHotkeyListener>>>,
|
||||
}
|
||||
|
||||
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::<HotkeyEvent>(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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user