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 filename: &'static str,
|
||||||
pub url: &'static str,
|
pub url: &'static str,
|
||||||
pub size: Megabytes,
|
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.
|
/// All metadata for a single downloadable model.
|
||||||
@@ -74,16 +76,19 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
|||||||
filename: "encoder-model.onnx",
|
filename: "encoder-model.onnx",
|
||||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx",
|
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx",
|
||||||
size: Megabytes(1),
|
size: Megabytes(1),
|
||||||
|
sha256: None,
|
||||||
},
|
},
|
||||||
ModelFile {
|
ModelFile {
|
||||||
filename: "model_int8.onnx_data",
|
filename: "model_int8.onnx_data",
|
||||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/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),
|
size: Megabytes(611),
|
||||||
|
sha256: None,
|
||||||
},
|
},
|
||||||
ModelFile {
|
ModelFile {
|
||||||
filename: "tokenizer.json",
|
filename: "tokenizer.json",
|
||||||
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json",
|
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json",
|
||||||
size: Megabytes(1),
|
size: Megabytes(1),
|
||||||
|
sha256: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
description: "Fastest local model — near-instant transcription",
|
description: "Fastest local model — near-instant transcription",
|
||||||
@@ -101,6 +106,7 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
|||||||
filename: "ggml-tiny.en.bin",
|
filename: "ggml-tiny.en.bin",
|
||||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
||||||
size: Megabytes(75),
|
size: Megabytes(75),
|
||||||
|
sha256: None,
|
||||||
}],
|
}],
|
||||||
description: "Bundled with app — works instantly",
|
description: "Bundled with app — works instantly",
|
||||||
},
|
},
|
||||||
@@ -117,6 +123,7 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
|||||||
filename: "ggml-base.en.bin",
|
filename: "ggml-base.en.bin",
|
||||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
||||||
size: Megabytes(142),
|
size: Megabytes(142),
|
||||||
|
sha256: None,
|
||||||
}],
|
}],
|
||||||
description: "Good balance of speed and accuracy",
|
description: "Good balance of speed and accuracy",
|
||||||
},
|
},
|
||||||
@@ -133,6 +140,7 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
|||||||
filename: "ggml-small.en.bin",
|
filename: "ggml-small.en.bin",
|
||||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
||||||
size: Megabytes(466),
|
size: Megabytes(466),
|
||||||
|
sha256: None,
|
||||||
}],
|
}],
|
||||||
description: "Accuracy-first English transcription",
|
description: "Accuracy-first English transcription",
|
||||||
},
|
},
|
||||||
@@ -149,6 +157,7 @@ static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
|
|||||||
filename: "ggml-medium.en.bin",
|
filename: "ggml-medium.en.bin",
|
||||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
|
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
|
||||||
size: Megabytes(1500),
|
size: Megabytes(1500),
|
||||||
|
sha256: None,
|
||||||
}],
|
}],
|
||||||
description: "Best Whisper accuracy — needs 4+ GB RAM",
|
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
|
# Model downloads
|
||||||
reqwest = { version = "0.12", features = ["stream"] }
|
reqwest = { version = "0.12", features = ["stream"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
# Download integrity verification
|
||||||
|
sha2 = "0.10"
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ pub async fn download(
|
|||||||
Ok(())
|
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(
|
async fn download_file(
|
||||||
file: &ModelFile,
|
file: &ModelFile,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
@@ -83,6 +88,7 @@ async fn download_file(
|
|||||||
progress: &(impl Fn(DownloadProgress) + Send),
|
progress: &(impl Fn(DownloadProgress) + Send),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
let part_path = dest.with_extension(
|
let part_path = dest.with_extension(
|
||||||
dest.extension()
|
dest.extension()
|
||||||
@@ -95,23 +101,72 @@ async fn download_file(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||||
|
|
||||||
let response = client
|
// Check for existing partial download (resume support)
|
||||||
.get(file.url)
|
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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
.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 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 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 {
|
while let Some(chunk) = stream.next().await {
|
||||||
let chunk = chunk
|
let chunk = chunk
|
||||||
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
|
||||||
std::io::Write::write_all(&mut out, &chunk)?;
|
std::io::Write::write_all(&mut out, &chunk)?;
|
||||||
|
if let Some(ref mut h) = hasher {
|
||||||
|
h.update(&chunk);
|
||||||
|
}
|
||||||
downloaded += chunk.len() as u64;
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
let percent = if total_bytes > 0 {
|
let percent = if total_bytes > 0 {
|
||||||
@@ -133,6 +188,21 @@ async fn download_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
drop(out);
|
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)?;
|
std::fs::rename(&part_path, dest)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ kon-transcription = { path = "../crates/transcription" }
|
|||||||
kon-ai-formatting = { path = "../crates/ai-formatting" }
|
kon-ai-formatting = { path = "../crates/ai-formatting" }
|
||||||
kon-storage = { path = "../crates/storage" }
|
kon-storage = { path = "../crates/storage" }
|
||||||
kon-cloud-providers = { path = "../crates/cloud-providers" }
|
kon-cloud-providers = { path = "../crates/cloud-providers" }
|
||||||
|
kon-hotkey = { path = "../crates/hotkey" }
|
||||||
|
|
||||||
# Tauri
|
# Tauri
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
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 audio;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
|
pub mod hotkey;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ pub fn run() {
|
|||||||
// Store init script for secondary windows (float, viewer)
|
// Store init script for secondary windows (float, viewer)
|
||||||
app.manage(PreferencesScript(init_script));
|
app.manage(PreferencesScript(init_script));
|
||||||
|
|
||||||
|
app.manage(commands::hotkey::HotkeyState::new());
|
||||||
|
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
whisper_engine: Arc::new(LocalEngine::new(
|
whisper_engine: Arc::new(LocalEngine::new(
|
||||||
EngineName::new("whisper"),
|
EngineName::new("whisper"),
|
||||||
@@ -152,6 +154,12 @@ pub fn run() {
|
|||||||
// Hardware
|
// Hardware
|
||||||
commands::hardware::probe_system,
|
commands::hardware::probe_system,
|
||||||
commands::hardware::rank_models,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Kon");
|
.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 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) {
|
async function registerGlobalHotkey(hotkey) {
|
||||||
|
if (hotkeyBackend === "unknown") return; // not yet initialised
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mod = await import("@tauri-apps/plugin-global-shortcut");
|
if (hotkeyBackend === "evdev") {
|
||||||
if (registeredHotkey) {
|
// evdev backend: start or update the Rust-side listener
|
||||||
await mod.unregister(registeredHotkey).catch(() => {});
|
if (registeredHotkey) {
|
||||||
}
|
await invoke("update_evdev_hotkey", { hotkey });
|
||||||
await mod.register(hotkey, () => {
|
} else {
|
||||||
if (page.current !== "dictation") page.current = "dictation";
|
await invoke("start_evdev_hotkey", { hotkey });
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
window.dispatchEvent(new CustomEvent("kon:toggle-recording"));
|
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) {
|
} catch (err) {
|
||||||
console.error("Hotkey registration failed:", 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(() => {
|
$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)
|
// Apply font size setting as CSS variable (legacy — kept for backwards compat)
|
||||||
@@ -100,6 +160,9 @@
|
|||||||
handleResize();
|
handleResize();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
// Detect and initialise the correct hotkey backend
|
||||||
|
await initHotkeyBackend();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const whisper = await invoke("list_models");
|
const whisper = await invoke("list_models");
|
||||||
const parakeet = await invoke("list_parakeet_models");
|
const parakeet = await invoke("list_parakeet_models");
|
||||||
@@ -113,11 +176,16 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener("resize", handleResize);
|
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")
|
import("@tauri-apps/plugin-global-shortcut")
|
||||||
.then((mod) => mod.unregister(registeredHotkey))
|
.then((mod) => mod.unregister(registeredHotkey))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (unlistenEvdev) {
|
||||||
|
unlistenEvdev();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user