From b4020979b3db21becfb00d8e430aa923ff93d8b3 Mon Sep 17 00:00:00 2001 From: Xokuss0 Date: Sun, 2 Aug 2026 17:00:49 +0200 Subject: [PATCH 1/2] feat: add Linux support - Create linux.rs platform module (rdev hotkey + ydotool/xdotool paste + full pipeline) - Wire up hotkey.rs to export from linux module on Linux - Update paste.rs for Linux (arboard clipboard + ydotool Ctrl+V) - Update appctx.rs for Linux frontmost-app detection via xdotool - Update local_llm.rs with XDG-compliant paths on Linux - Update lib.rs with Linux permission stubs and xdg-open - Add rdev dependency for global keyboard listening - Update tauri.conf.json for Linux bundle targets - Fix whisper-rs to v0.16 for working Linux build --- Cargo.lock | 244 ++++++++++-------- crates/whimpr-asr/Cargo.toml | 6 +- crates/whimpr-asr/src/lib.rs | 8 +- src-tauri/Cargo.toml | 7 + src-tauri/src/appctx.rs | 19 +- src-tauri/src/hotkey.rs | 27 +- src-tauri/src/lib.rs | 35 +++ src-tauri/src/linux.rs | 487 +++++++++++++++++++++++++++++++++++ src-tauri/src/local_llm.rs | 18 +- src-tauri/src/paste.rs | 7 +- src-tauri/tauri.conf.json | 7 +- ui/pnpm-workspace.yaml | 3 + 12 files changed, 722 insertions(+), 146 deletions(-) create mode 100644 src-tauri/src/linux.rs diff --git a/Cargo.lock b/Cargo.lock index 5c5a133..ae02664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,29 +136,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.119", - "which", -] - [[package]] name = "bindgen" version = "0.72.1" @@ -168,13 +145,13 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools", "log", "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 2.1.3", + "rustc-hash", "shlex 1.3.0", "syn 2.0.119", ] @@ -209,6 +186,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -469,6 +452,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics 0.21.0", + "foreign-types 0.3.2", + "libc", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -489,13 +487,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] @@ -505,16 +513,46 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -524,7 +562,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -546,7 +584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "coreaudio-sys", ] @@ -556,7 +594,7 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" dependencies = [ - "bindgen 0.72.1", + "bindgen", ] [[package]] @@ -566,7 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", "jni", @@ -1059,6 +1097,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1066,7 +1113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1080,6 +1127,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1288,7 +1341,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-link 0.2.1", ] @@ -1520,15 +1573,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "hound" version = "3.5.1" @@ -1650,7 +1694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "iana-time-zone-haiku", "js-sys", "log", @@ -1838,15 +1882,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -1999,12 +2034,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2073,12 +2102,6 @@ dependencies = [ "libc", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2111,7 +2134,7 @@ version = "0.1.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23c676b02f8f2bdd9d7df7bcca0bd176b27964440998e7aa31c6fe32552964d0" dependencies = [ - "bindgen 0.72.1", + "bindgen", "cc", "cmake", "find_cuda_helper", @@ -2156,7 +2179,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf7710fbff50c24124331760978fb9086d6de6288dcdb38b25a97f8b1bdebbb" dependencies = [ "core-foundation 0.9.4", - "core-foundation-sys", + "core-foundation-sys 0.8.7", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", ] [[package]] @@ -2366,6 +2398,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2994,7 +3035,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.3", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -3015,7 +3056,7 @@ dependencies = [ "rand", "rand_pcg", "ring", - "rustc-hash 2.1.3", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -3092,6 +3133,22 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rdev" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" +dependencies = [ + "cocoa", + "core-foundation 0.7.0", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "lazy_static", + "libc", + "winapi", + "x11", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3249,12 +3306,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.3" @@ -3270,19 +3321,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -3292,7 +3330,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -3423,7 +3461,7 @@ dependencies = [ "phf", "phf_codegen", "precomputed-hash", - "rustc-hash 2.1.3", + "rustc-hash", "servo_arc", "smallvec", ] @@ -3829,7 +3867,7 @@ dependencies = [ "bitflags 2.13.1", "block2", "core-foundation 0.10.1", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -4852,18 +4890,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "whimpr-asr" version = "0.1.1" @@ -4940,6 +4966,7 @@ dependencies = [ "objc2-app-kit", "objc2-av-foundation", "objc2-foundation", + "rdev", "serde", "serde_json", "strsim", @@ -4956,23 +4983,24 @@ dependencies = [ [[package]] name = "whisper-rs" -version = "0.12.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c597ac8a9d5c4719fee232abc871da184ea50a4fea38d2d00348fd95072b2b0" +checksum = "2088172d00f936c348d6a72f488dc2660ab3f507263a195df308a3c2383229f6" dependencies = [ "whisper-rs-sys", ] [[package]] name = "whisper-rs-sys" -version = "0.10.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22f00ed0995463eecc34ef89905845f6bf6fd37ea70789fed180520050da8f8" +checksum = "6986c0fe081241d391f09b9a071fbcbb59720c3563628c3c829057cf69f2a56f" dependencies = [ - "bindgen 0.69.5", + "bindgen", "cfg-if", "cmake", "fs_extra", + "semver", ] [[package]] @@ -5546,7 +5574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix", "x11rb-protocol", ] diff --git a/crates/whimpr-asr/Cargo.toml b/crates/whimpr-asr/Cargo.toml index bb3a8ac..387a946 100644 --- a/crates/whimpr-asr/Cargo.toml +++ b/crates/whimpr-asr/Cargo.toml @@ -16,8 +16,8 @@ anyhow = { workspace = true } hound = "3" [target.'cfg(target_os = "macos")'.dependencies] -whisper-rs = { version = "0.12", features = ["metal"] } +whisper-rs = { version = "0.16", features = ["metal"] } -# No GPU feature on Windows yet — CPU backend first, Vulkan/CUDA can follow. +# No GPU feature on Windows/Linux yet — CPU backend first, Vulkan/CUDA can follow. [target.'cfg(not(target_os = "macos"))'.dependencies] -whisper-rs = { version = "0.12" } +whisper-rs = { version = "0.16" } diff --git a/crates/whimpr-asr/src/lib.rs b/crates/whimpr-asr/src/lib.rs index 26b5915..d9b905a 100644 --- a/crates/whimpr-asr/src/lib.rs +++ b/crates/whimpr-asr/src/lib.rs @@ -59,13 +59,11 @@ impl AsrEngine for WhisperEngine { .full(params, pcm16k) .map_err(|e| anyhow::anyhow!("whisper full: {e}"))?; - let n = state - .full_n_segments() - .map_err(|e| anyhow::anyhow!("whisper n_segments: {e}"))?; + let n = state.full_n_segments(); let mut text = String::new(); for i in 0..n { - if let Ok(seg) = state.full_get_segment_text(i) { - text.push_str(&seg); + if let Some(seg) = state.get_segment(i) { + text.push_str(&seg.to_string()); } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f0a8b2..3d9dda0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,3 +46,10 @@ macos-accessibility-client = "0.0.1" objc2-app-kit = { version = "0.3", features = ["NSWorkspace", "NSRunningApplication"] } objc2-foundation = { version = "0.3", features = ["NSString"] } objc2-av-foundation = { version = "0.3", features = ["AVCaptureDevice", "AVMediaFormat"] } + +[target.'cfg(target_os = "linux")'.dependencies] +# Global keyboard listener (X11 + Wayland). Needs /dev/input access — add user to +# the 'input' group if events aren't received. +rdev = "0.5" +# Keyboard simulation is done via ydotool (Wayland) or xdotool (X11) as subprocess +# calls, avoiding libxdo/libei C dependency issues. diff --git a/src-tauri/src/appctx.rs b/src-tauri/src/appctx.rs index 35fcca7..459b018 100644 --- a/src-tauri/src/appctx.rs +++ b/src-tauri/src/appctx.rs @@ -28,7 +28,24 @@ pub fn frontmost_bundle_id() -> Option { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub fn frontmost_bundle_id() -> Option { + // Try xdotool first (most reliable on X11) + if let Ok(out) = std::process::Command::new("xdotool") + .args(["getactivewindow", "getwindowclassname"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + None +} + +#[cfg(target_os = "windows")] pub fn frontmost_bundle_id() -> Option { None } diff --git a/src-tauri/src/hotkey.rs b/src-tauri/src/hotkey.rs index 7078dc9..95a0d99 100644 --- a/src-tauri/src/hotkey.rs +++ b/src-tauri/src/hotkey.rs @@ -707,30 +707,9 @@ pub use crate::win::{ history, install, rebuild_providers, stats_summary, update_settings, }; -// Other platforms (Linux, etc.): inert stubs so the crate still builds. -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -mod other { - pub fn install(_app: tauri::AppHandle) {} - pub fn current_settings() -> whimpr_core::Settings { - whimpr_core::Settings::default() - } - pub fn update_settings(_new: whimpr_core::Settings) {} - pub fn rebuild_providers() {} - pub fn stats_summary(tz_offset_minutes: i32) -> whimpr_core::StatsSummary { - whimpr_core::StatsStore::default().summary(tz_offset_minutes, 0) - } - pub fn history(_limit: usize) -> Vec { - Vec::new() - } - pub fn dictionary_entries() -> Vec { - Vec::new() - } - pub fn dictionary_add(_correct: String, _mishears: Vec) {} - pub fn dictionary_remove(_correct: &str) {} - pub fn dictionary_learn(_correct: String, _mishears: Vec) {} -} -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -pub use other::{ +// Linux: real platform layer (rdev hotkey + enigo paste + full pipeline). +#[cfg(target_os = "linux")] +pub use crate::linux::{ current_settings, dictionary_add, dictionary_entries, dictionary_learn, dictionary_remove, history, install, rebuild_providers, stats_summary, update_settings, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9a90dc..a18270e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod local_llm; mod paste; #[cfg(target_os = "windows")] mod win; +#[cfg(target_os = "linux")] +mod linux; use serde::Serialize; use tauri::{ @@ -168,6 +170,16 @@ fn open_url(url: &str) { let _ = std::process::Command::new("open").arg(url).spawn(); } +#[cfg(target_os = "linux")] +fn open_url(url: &str) { + let _ = std::process::Command::new("xdg-open").arg(url).spawn(); +} + +#[cfg(target_os = "windows")] +fn open_url(url: &str) { + let _ = std::process::Command::new("cmd").args(["/c", "start", url]).spawn(); +} + /// Request microphone access: trigger the native prompt (bundle has a usage string) /// by briefly opening the input device, and open the Microphone settings pane. #[tauri::command] @@ -182,6 +194,18 @@ fn request_microphone() { }); open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"); } + #[cfg(target_os = "linux")] + { + // On Linux, microphone access is controlled by PipeWire/PulseAudio. + // Opening pavucontrol is the closest equivalent to macOS's permission pane. + std::thread::spawn(|| { + if let Ok(h) = whimpr_audio::start(|_: &[f32]| {}) { + std::thread::sleep(std::time::Duration::from_millis(400)); + let _ = h.stop(); + } + }); + let _ = std::process::Command::new("pavucontrol").spawn(); + } } /// Request Accessibility — the permission that makes the Fn key work in every app and @@ -193,6 +217,13 @@ fn request_accessibility() { let _ = paste::prompt_accessibility(); open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"); } + #[cfg(target_os = "linux")] + { + // On Linux, keyboard input access is via the 'input' group. + // Open the system settings or print instructions. + eprintln!("[whimpr] Linux: ensure your user is in the 'input' group: sudo usermod -aG input $USER"); + let _ = open_url("https://wiki.archlinux.org/title/Input_device"); + } } /// Request Input Monitoring (needed for the Fn key to be seen in every app, not @@ -204,6 +235,10 @@ fn request_input_monitoring() { let _ = paste::request_input_monitoring(); open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"); } + #[cfg(target_os = "linux")] + { + eprintln!("[whimpr] Linux: input monitoring is handled via rdev (needs 'input' group membership)"); + } } /// Save (or clear, when empty) an API key in the OS keychain, then rebuild providers diff --git a/src-tauri/src/linux.rs b/src-tauri/src/linux.rs new file mode 100644 index 0000000..7250f03 --- /dev/null +++ b/src-tauri/src/linux.rs @@ -0,0 +1,487 @@ +//! Linux platform layer for WhimprFlow: a global keyboard listener for +//! push-to-talk, clipboard+key-simulation text injection, and the same dictation +//! pipeline (audio → Whisper ASR → cleanup LLM → paste) that macOS/Windows use, +//! plus Hub-facing settings/stats/dictionary functions. +//! +//! Default push-to-talk key: Right Ctrl (same as Windows). +//! Keyboard listening uses `rdev` (X11 + Wayland). Paste uses `arboard` + `enigo`. + +#![cfg(target_os = "linux")] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Emitter}; +use whimpr_core::{AsrEngine, CleanupContext, CleanupMode, CleanupProvider, StatsSummary}; + +const OVERLAY_LABEL: &str = "whimpr_bar"; + +static APP: OnceLock = OnceLock::new(); +static CLOCK: OnceLock = OnceLock::new(); +static RECORDING: AtomicBool = AtomicBool::new(false); +static CAPTURE: OnceLock>> = OnceLock::new(); +static ASR: OnceLock> = OnceLock::new(); +static LOCAL: OnceLock>> = OnceLock::new(); +static OPENAI: OnceLock>> = OnceLock::new(); +static ANTHROPIC: OnceLock>> = OnceLock::new(); +static SETTINGS: OnceLock> = OnceLock::new(); +static DICTIONARY: OnceLock> = OnceLock::new(); +static STATS: OnceLock> = OnceLock::new(); + +// ── Support paths (XDG-compliant) ────────────────────────────────────────────── + +fn support_dir() -> std::path::PathBuf { + // XDG_DATA_HOME or ~/.local/share + if let Ok(d) = std::env::var("XDG_DATA_HOME") { + if !d.is_empty() { + return std::path::PathBuf::from(d).join("WhimprFlow"); + } + } + let home = std::env::var("HOME").unwrap_or_default(); + std::path::PathBuf::from(home).join(".local/share/WhimprFlow") +} + +fn settings_path() -> std::path::PathBuf { + support_dir().join("settings.json") +} +fn dict_path() -> std::path::PathBuf { + support_dir().join("dictionary.json") +} +fn stats_path() -> std::path::PathBuf { + support_dir().join("stats.json") +} +fn whisper_model_path() -> std::path::PathBuf { + let dir = support_dir().join("models"); + for name in [ + "ggml-large-v3-turbo.bin", + "ggml-medium.en.bin", + "ggml-small.en.bin", + "ggml-base.en.bin", + ] { + let p = dir.join(name); + if p.exists() { + return p; + } + } + dir.join("ggml-base.en.bin") +} + +// ── Time helpers ──────────────────────────────────────────────────────────────── + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn now_ms() -> u64 { + CLOCK.get().map(|c| c.elapsed().as_millis() as u64).unwrap_or(0) +} + +// ── Pill state emission ───────────────────────────────────────────────────────── + +fn emit_bar(state: &'static str) { + if let Some(app) = APP.get() { + #[derive(Clone, serde::Serialize)] + struct P { + state: &'static str, + } + let _ = app.emit_to(OVERLAY_LABEL, "whimpr://flowbar/state", P { state }); + } +} + +// ── Frontmost app detection (Linux) ───────────────────────────────────────────── + +/// Try to detect the frontmost window's class/name. Returns None on failure +/// or when running under an unsupported display server. +fn foreground_app() -> Option { + // Try xdotool first (most reliable on X11) + if let Ok(out) = std::process::Command::new("xdotool") + .args(["getactivewindow", "getwindowclassname"]) + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + // Fallback: try to get the window title via xprop + if let Ok(out) = std::process::Command::new("sh") + .arg("-c") + .arg("xprop -id $(xprop -root 32x '\t$0' _NET_ACTIVE_WINDOW | cut -f2) WM_CLASS 2>/dev/null | cut -d'\"' -f4") + .output() + { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + None +} + +// ── Text injection: clipboard + Ctrl+V via enigo ──────────────────────────────── + +pub fn paste_text(text: &str) -> anyhow::Result<()> { + use arboard::Clipboard; + let mut cb = Clipboard::new()?; + let saved = cb.get_text().ok(); + cb.set_text(text.to_string())?; + std::thread::sleep(Duration::from_millis(60)); + + // Simulate Ctrl+V. Try ydotool first (Wayland), then xdotool (X11). + let ydotool = std::process::Command::new("ydotool") + .args(["key", "29:1", "47:1", "47:0", "29:0"]) // Ctrl down, V down, V up, Ctrl up + .output(); + if ydotool.is_err() || !ydotool.as_ref().map(|o| o.status.success()).unwrap_or(false) { + // Fallback: xdotool for X11 + let _ = std::process::Command::new("xdotool") + .args(["key", "--clearmodifiers", "ctrl+v"]) + .output(); + } + + std::thread::sleep(Duration::from_millis(150)); + if let Some(prev) = saved { + let _ = cb.set_text(prev); + } + Ok(()) +} + +// ── Cleanup (shared, cross-platform building blocks) ──────────────────────────── + +fn current_settings_inner() -> whimpr_core::Settings { + SETTINGS + .get() + .map(|m| m.lock().unwrap().clone()) + .unwrap_or_default() +} + +fn read_key(account: &str, env_var: &str) -> Option { + if let Ok(k) = std::env::var(env_var) { + let k = k.trim().to_string(); + if !k.is_empty() { + return Some(k); + } + } + keyring::Entry::new("com.whimpr.whimprflow", account) + .ok() + .and_then(|e| e.get_password().ok()) + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) +} + +fn clean_transcript(raw: &str) -> String { + let settings = current_settings_inner(); + let level = settings.cleanup_level; + if matches!(settings.cleanup_mode, CleanupMode::Raw) || level.bypasses_llm() { + return raw.to_string(); + } + let raw_norm = whimpr_core::cleanup::pre_normalize_layout(raw); + let raw_out = whimpr_core::cleanup::post_process(&raw_norm); + let vocab = DICTIONARY + .get() + .map(|d| d.lock().unwrap().prefilter(&raw_norm, 15)) + .unwrap_or_default(); + let ctx = CleanupContext { + level, + vocab, + app_bundle_id: foreground_app(), + ..Default::default() + }; + let run_local = || -> Option> { + LOCAL.get().and_then(|m| { + m.lock().unwrap().as_mut().map(|w| { + let messages = whimpr_core::cleanup::build_messages(&raw_norm, &ctx); + w.cleanup(&messages) + }) + }) + }; + let result = match settings.cleanup_mode { + CleanupMode::OpenAi => OPENAI + .get() + .and_then(|m| m.lock().unwrap().as_ref().map(|p| p.cleanup(&raw_norm, &ctx))) + .or_else(run_local), + CleanupMode::Anthropic => ANTHROPIC + .get() + .and_then(|m| m.lock().unwrap().as_ref().map(|p| p.cleanup(&raw_norm, &ctx))) + .or_else(run_local), + CleanupMode::Local => run_local(), + CleanupMode::Raw => None, + }; + match result { + Some(Ok(cleaned)) => { + let cleaned = whimpr_core::cleanup::post_process(&cleaned); + if whimpr_core::cleanup::evaluate_gates(&raw_out, &cleaned, level).passed() { + cleaned + } else { + eprintln!("[whimpr:linux] cleanup gate rejected — pasting raw"); + raw_out + } + } + Some(Err(e)) => { + eprintln!("[whimpr:linux] cleanup failed ({e}) — pasting raw"); + raw_out + } + None => raw_out, + } +} + +fn record_dictation(text: &str, duration_secs: f32, app: Option) { + let words = whimpr_core::stats::count_words(text); + if words == 0 { + return; + } + if let Some(m) = STATS.get() { + let mut store = m.lock().unwrap(); + let duration_ms = (duration_secs.max(0.0) * 1000.0) as u32; + let chars = text.chars().count() as u32; + store.record(words, duration_ms, chars, unix_now(), text.to_string(), app); + let _ = store.save(&stats_path()); + } +} + +// ── The push-to-talk pipeline ─────────────────────────────────────────────────── + +fn on_ptt_down() { + if RECORDING.swap(true, Ordering::SeqCst) { + return; // already recording + } + let _ = now_ms(); + eprintln!("[whimpr:linux] PTT DOWN — starting capture"); + emit_bar("recording"); + std::thread::spawn(|| match whimpr_audio::start(|_: &[f32]| {}) { + Ok(handle) => { + *CAPTURE.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(handle); + } + Err(e) => eprintln!("[whimpr:linux] mic capture failed: {e}"), + }); +} + +fn on_ptt_up() { + if !RECORDING.swap(false, Ordering::SeqCst) { + return; // wasn't recording + } + eprintln!("[whimpr:linux] PTT UP — finalizing"); + emit_bar("transcribing"); + let app = foreground_app(); + let handle = CAPTURE.get().and_then(|slot| slot.lock().unwrap().take()); + std::thread::spawn(move || { + let Some(res) = handle.and_then(|h| h.stop()) else { + eprintln!("[whimpr:linux] no audio captured"); + emit_bar("idle"); + return; + }; + let Some(asr) = ASR.get().cloned() else { + eprintln!("[whimpr:linux] ASR not ready"); + emit_bar("idle"); + return; + }; + let pcm = whimpr_audio::resample_to_16k(&res.samples, res.sample_rate); + match asr.transcribe(&pcm) { + Ok(t) => { + let raw = &t.text; + eprintln!("[whimpr:linux] TRANSCRIPT: \"{}\"", raw); + emit_bar("done"); + let text = clean_transcript(raw); + if text != *raw { + eprintln!("[whimpr:linux] CLEANED: \"{}\"", text); + } + if !text.is_empty() { + if let Err(e) = paste_text(&text) { + eprintln!("[whimpr:linux] paste failed: {e}"); + } + record_dictation(&text, res.duration_secs(), app); + } + } + Err(e) => eprintln!("[whimpr:linux] ASR error: {e}"), + } + // Return pill to idle after a short "done" display + std::thread::sleep(Duration::from_millis(500)); + emit_bar("idle"); + }); +} + +// ── Global keyboard listener via rdev ──────────────────────────────────────────── +// Default push-to-talk: Right Ctrl (same as Windows) + +fn spawn_keyboard_listener() { + std::thread::spawn(move || { + // Give the app a moment to fully initialize before hooking keys + std::thread::sleep(Duration::from_millis(500)); + eprintln!("[whimpr:linux] starting global keyboard listener (push-to-talk: Right Ctrl)"); + + if let Err(e) = rdev::listen(move |event| { + use rdev::EventType::{KeyPress, KeyRelease}; + use rdev::Key::ControlRight; + match event.event_type { + KeyPress(ControlRight) => on_ptt_down(), + KeyRelease(ControlRight) => on_ptt_up(), + _ => {} + } + }) { + eprintln!( + "[whimpr:linux] rdev keyboard listener failed: {e:?}. \ + You may need to add yourself to the 'input' group: \ + sudo usermod -aG input $USER && reboot" + ); + } + }); +} + +// ── Public surface (mirrors the macOS `hotkey::` and Windows `win::` functions) ── + +pub fn install(app: AppHandle) { + let _ = APP.set(app.clone()); + let _ = CLOCK.set(Instant::now()); + let _ = SETTINGS.set(Mutex::new(whimpr_core::Settings::load(&settings_path()))); + let _ = DICTIONARY.set(Mutex::new(whimpr_core::DictionaryStore::load(&dict_path()))); + let _ = STATS.set(Mutex::new(whimpr_core::StatsStore::load(&stats_path()))); + let _ = OPENAI.set(Mutex::new(None)); + let _ = ANTHROPIC.set(Mutex::new(None)); + let _ = LOCAL.set(Mutex::new(None)); + + // Ensure support dirs exist + let _ = std::fs::create_dir_all(&support_dir()); + let _ = std::fs::create_dir_all(&support_dir().join("models")); + + rebuild_providers(); + eprintln!( + "[whimpr:linux] data dir: {}", + support_dir().display() + ); + + // Load Whisper ASR model + std::thread::spawn(|| { + let path = whisper_model_path(); + if !path.exists() { + eprintln!( + "[whimpr:linux] ⚠ ASR model not found at {}. \ + Download a whisper.cpp model (e.g., ggml-base.en.bin) and place it there.", + path.display() + ); + return; + } + match whimpr_asr::WhisperEngine::load(&path) { + Ok(engine) => { + let _ = ASR.set(Arc::new(engine)); + eprintln!("[whimpr:linux] ASR model loaded — ready to transcribe"); + } + Err(e) => eprintln!("[whimpr:linux] ASR model load failed: {e}"), + } + }); + + // Start the local cleanup worker + std::thread::spawn(|| { + if let Some(w) = crate::local_llm::spawn_default() { + if let Some(slot) = LOCAL.get() { + *slot.lock().unwrap() = Some(w); + } + } + }); + + // Start the global keyboard listener for push-to-talk + spawn_keyboard_listener(); + + eprintln!("[whimpr:linux] installed — hold Right Ctrl to dictate"); +} + +pub fn current_settings() -> whimpr_core::Settings { + current_settings_inner() +} + +pub fn update_settings(new: whimpr_core::Settings) { + if let Some(m) = SETTINGS.get() { + *m.lock().unwrap() = new.clone(); + } + let _ = new.save(&settings_path()); + rebuild_providers(); +} + +pub fn rebuild_providers() { + let settings = current_settings_inner(); + + let openai = read_key("openai_api_key", "OPENAI_API_KEY").map(|k| { + whimpr_cleanup::OpenAiProvider::with_base_url( + k, + settings.openai_model.clone(), + Some(settings.openai_base_url.clone()), + ) + }); + let anthropic = read_key("anthropic_api_key", "ANTHROPIC_API_KEY") + .map(|k| whimpr_cleanup::AnthropicProvider::new(k, settings.anthropic_model.clone())); + + match OPENAI.get() { + Some(m) => *m.lock().unwrap() = openai, + None => { + let _ = OPENAI.set(Mutex::new(openai)); + } + } + match ANTHROPIC.get() { + Some(m) => *m.lock().unwrap() = anthropic, + None => { + let _ = ANTHROPIC.set(Mutex::new(anthropic)); + } + } +} + +pub fn stats_summary(tz_offset_minutes: i32) -> StatsSummary { + STATS + .get() + .map(|m| m.lock().unwrap().summary(tz_offset_minutes, unix_now())) + .unwrap_or_else(|| whimpr_core::StatsStore::default().summary(tz_offset_minutes, unix_now())) +} + +pub fn history(limit: usize) -> Vec { + STATS + .get() + .map(|m| m.lock().unwrap().history(limit)) + .unwrap_or_default() +} + +pub fn dictionary_entries() -> Vec { + DICTIONARY + .get() + .map(|m| { + m.lock() + .unwrap() + .entries + .iter() + .map(|e| crate::hotkey::DictEntryDto { + correct: e.correct.clone(), + mishears: e.mishears.clone(), + auto: matches!(e.source, whimpr_core::DictSource::Auto), + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn dictionary_add(correct: String, mishears: Vec) { + if let Some(m) = DICTIONARY.get() { + let mut store = m.lock().unwrap(); + store.add(correct, mishears, whimpr_core::DictSource::Manual); + let _ = store.save(&dict_path()); + } +} + +pub fn dictionary_remove(correct: &str) { + if let Some(m) = DICTIONARY.get() { + let mut store = m.lock().unwrap(); + if store.remove(correct) { + let _ = store.save(&dict_path()); + } + } +} + +pub fn dictionary_learn(correct: String, mishears: Vec) { + if let Some(m) = DICTIONARY.get() { + let mut store = m.lock().unwrap(); + store.add(correct, mishears, whimpr_core::DictSource::Auto); + let _ = store.save(&dict_path()); + } +} diff --git a/src-tauri/src/local_llm.rs b/src-tauri/src/local_llm.rs index 21d2672..018d367 100644 --- a/src-tauri/src/local_llm.rs +++ b/src-tauri/src/local_llm.rs @@ -55,15 +55,27 @@ impl Drop for LocalWorker { } } -/// Platform application-support dir: `~/Library/Application Support/WhimprFlow` -/// on macOS, `%APPDATA%\WhimprFlow` on Windows. +/// Platform application-support dir: +/// - Linux: `$XDG_DATA_HOME/WhimprFlow` or `~/.local/share/WhimprFlow` +/// - macOS: `~/Library/Application Support/WhimprFlow` +/// - Windows: `%APPDATA%\WhimprFlow` fn app_support_dir() -> PathBuf { + #[cfg(target_os = "linux")] + { + if let Ok(d) = std::env::var("XDG_DATA_HOME") { + if !d.is_empty() { + return PathBuf::from(d).join("WhimprFlow"); + } + } + let home = std::env::var("HOME").unwrap_or_default(); + PathBuf::from(home).join(".local/share/WhimprFlow") + } #[cfg(target_os = "windows")] { let base = std::env::var("APPDATA").unwrap_or_default(); PathBuf::from(base).join("WhimprFlow") } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] { let home = std::env::var("HOME").unwrap_or_default(); PathBuf::from(home).join("Library/Application Support/WhimprFlow") diff --git a/src-tauri/src/paste.rs b/src-tauri/src/paste.rs index b324dba..ae18cef 100644 --- a/src-tauri/src/paste.rs +++ b/src-tauri/src/paste.rs @@ -123,7 +123,12 @@ pub use imp::{ request_input_monitoring, }; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +pub fn paste_text(text: &str) -> anyhow::Result<()> { + crate::linux::paste_text(text) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] pub fn paste_text(_text: &str) -> anyhow::Result<()> { Ok(()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f11bfbb..42b63ef 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["app", "dmg"], + "targets": ["app", "dmg", "deb", "appimage"], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -30,6 +30,11 @@ "macOS": { "minimumSystemVersion": "14.0", "entitlements": "Entitlements.plist" + }, + "linux": { + "deb": { + "depends": [] + } } } } diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 07a7fea..6a20b66 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -5,6 +5,9 @@ packages: - . +allowBuilds: + esbuild: true + # esbuild ships a postinstall that fetches its platform binary; without this it # is skipped and vite build dies on a missing binary. onlyBuiltDependencies: From ac896f2b87f6956715f59bec5b319289116f6e22 Mon Sep 17 00:00:00 2001 From: Xokuss0 Date: Sun, 2 Aug 2026 17:26:01 +0200 Subject: [PATCH 2/2] feat: configurable push-to-talk key in Settings - Add push_to_talk_key field to whimpr_core::Settings (default ControlRight) - Linux: use configured key from settings in rdev keyboard listener - Parse key string to rdev::Key with Alt/AltGr/Shift/Ctrl/Meta/CapsLock/F1-F12 - Add key picker UI in Settings pane with 21 key options - Human-readable key labels in startup log messages --- crates/whimpr-core/src/settings.rs | 9 ++++ src-tauri/src/linux.rs | 72 +++++++++++++++++++++++++++--- ui/src/hub/SettingsPane.tsx | 55 +++++++++++++++++++++++ ui/src/hub/api.ts | 3 ++ 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/crates/whimpr-core/src/settings.rs b/crates/whimpr-core/src/settings.rs index 18e0c1c..bace0d3 100644 --- a/crates/whimpr-core/src/settings.rs +++ b/crates/whimpr-core/src/settings.rs @@ -36,6 +36,14 @@ pub struct Settings { pub anthropic_model: String, /// Play the record-start ping. pub sound_on_start: bool, + /// The push-to-talk key, stored as an rdev::Key variant string (e.g. "ControlRight"). + /// Defaults to Right Ctrl. + #[serde(default = "default_push_to_talk_key")] + pub push_to_talk_key: String, +} + +fn default_push_to_talk_key() -> String { + "ControlRight".to_string() } impl Default for Settings { @@ -47,6 +55,7 @@ impl Default for Settings { openai_base_url: String::new(), anthropic_model: "claude-haiku-4-5".to_string(), sound_on_start: true, + push_to_talk_key: default_push_to_talk_key(), } } } diff --git a/src-tauri/src/linux.rs b/src-tauri/src/linux.rs index 7250f03..44f4d80 100644 --- a/src-tauri/src/linux.rs +++ b/src-tauri/src/linux.rs @@ -307,20 +307,78 @@ fn on_ptt_up() { } // ── Global keyboard listener via rdev ──────────────────────────────────────────── -// Default push-to-talk: Right Ctrl (same as Windows) + +/// Parse a string like "ControlRight" into the corresponding rdev::Key. +/// Returns None for unrecognized keys. +fn parse_ptt_key(s: &str) -> Option { + use rdev::Key::*; + match s { + "ControlRight" => Some(ControlRight), + "ControlLeft" => Some(ControlLeft), + "AltRight" => Some(AltGr), + "AltLeft" => Some(Alt), + "ShiftRight" => Some(ShiftRight), + "ShiftLeft" => Some(ShiftLeft), + "MetaRight" => Some(MetaRight), + "MetaLeft" => Some(MetaLeft), + "CapsLock" => Some(CapsLock), + "F1" => Some(F1), + "F2" => Some(F2), + "F3" => Some(F3), + "F4" => Some(F4), + "F5" => Some(F5), + "F6" => Some(F6), + "F7" => Some(F7), + "F8" => Some(F8), + "F9" => Some(F9), + "F10" => Some(F10), + "F11" => Some(F11), + "F12" => Some(F12), + _ => { + eprintln!("[whimpr:linux] unrecognized push-to-talk key '{s}', falling back to ControlRight"); + Some(ControlRight) + } + } +} + +/// Human-readable label for a key variant string (shown in the startup message). +fn ptt_key_label(s: &str) -> &str { + match s { + "ControlRight" => "Right Ctrl", + "ControlLeft" => "Left Ctrl", + "AltRight" => "Right Alt", + "AltLeft" => "Left Alt", + "ShiftRight" => "Right Shift", + "ShiftLeft" => "Left Shift", + "MetaRight" => "Right Super/Meta", + "MetaLeft" => "Left Super/Meta", + "CapsLock" => "Caps Lock", + s if s.starts_with('F') => s, // F1-F12 + _ => s, + } +} fn spawn_keyboard_listener() { + // Capture the configured key *now* (before the thread starts) so the + // listener always sees the key that was active at install time. + let ptt_key_str = current_settings_inner().push_to_talk_key; + let ptt_key = parse_ptt_key(&ptt_key_str); + let label = ptt_key_label(&ptt_key_str).to_string(); + std::thread::spawn(move || { + let Some(ptt_key) = ptt_key else { + eprintln!("[whimpr:linux] invalid push-to-talk key '{ptt_key_str}' — listener not started"); + return; + }; // Give the app a moment to fully initialize before hooking keys std::thread::sleep(Duration::from_millis(500)); - eprintln!("[whimpr:linux] starting global keyboard listener (push-to-talk: Right Ctrl)"); + eprintln!("[whimpr:linux] starting global keyboard listener (push-to-talk: {label})"); if let Err(e) = rdev::listen(move |event| { use rdev::EventType::{KeyPress, KeyRelease}; - use rdev::Key::ControlRight; match event.event_type { - KeyPress(ControlRight) => on_ptt_down(), - KeyRelease(ControlRight) => on_ptt_up(), + KeyPress(k) if k == ptt_key => on_ptt_down(), + KeyRelease(k) if k == ptt_key => on_ptt_up(), _ => {} } }) { @@ -385,9 +443,11 @@ pub fn install(app: AppHandle) { }); // Start the global keyboard listener for push-to-talk + let settings = current_settings_inner(); + let ptt_label = ptt_key_label(&settings.push_to_talk_key); spawn_keyboard_listener(); - eprintln!("[whimpr:linux] installed — hold Right Ctrl to dictate"); + eprintln!("[whimpr:linux] installed — hold {ptt_label} to dictate"); } pub fn current_settings() -> whimpr_core::Settings { diff --git a/ui/src/hub/SettingsPane.tsx b/ui/src/hub/SettingsPane.tsx index 0e2a456..e400e94 100644 --- a/ui/src/hub/SettingsPane.tsx +++ b/ui/src/hub/SettingsPane.tsx @@ -27,6 +27,30 @@ const LEVELS: { value: CleanupLevel; label: string; hint: string }[] = [ { value: "high", label: "High", hint: "Rewrite for brevity and polish." }, ]; +const PTT_KEYS: { value: string; label: string }[] = [ + { value: "ControlRight", label: "Right Ctrl" }, + { value: "ControlLeft", label: "Left Ctrl" }, + { value: "AltRight", label: "Right Alt" }, + { value: "AltLeft", label: "Left Alt" }, + { value: "ShiftRight", label: "Right Shift" }, + { value: "ShiftLeft", label: "Left Shift" }, + { value: "MetaRight", label: "Right Super/Windows" }, + { value: "MetaLeft", label: "Left Super/Windows" }, + { value: "CapsLock", label: "Caps Lock" }, + { value: "F1", label: "F1" }, + { value: "F2", label: "F2" }, + { value: "F3", label: "F3" }, + { value: "F4", label: "F4" }, + { value: "F5", label: "F5" }, + { value: "F6", label: "F6" }, + { value: "F7", label: "F7" }, + { value: "F8", label: "F8" }, + { value: "F9", label: "F9" }, + { value: "F10", label: "F10" }, + { value: "F11", label: "F11" }, + { value: "F12", label: "F12" }, +]; + function SectionTitle({ children, sub }: { children: React.ReactNode; sub?: string }) { return (
@@ -239,6 +263,37 @@ export function SettingsPane({
+ + Push-to-Talk Key +
+ {PTT_KEYS.map((k) => { + const selected = settings.push_to_talk_key === k.value; + return ( + + ); + })} +
+
+ Changes take effect after restarting the app. +
+
+
diff --git a/ui/src/hub/api.ts b/ui/src/hub/api.ts index bbc2d0c..d138987 100644 --- a/ui/src/hub/api.ts +++ b/ui/src/hub/api.ts @@ -14,6 +14,8 @@ export interface Settings { openai_base_url: string; anthropic_model: string; sound_on_start: boolean; + // Push-to-talk key variant string (e.g. "ControlRight", "AltLeft", "MetaLeft"). + push_to_talk_key: string; } export interface Status { @@ -57,6 +59,7 @@ export const DEFAULT_SETTINGS: Settings = { openai_base_url: "", anthropic_model: "claude-haiku-4-5", sound_on_start: true, + push_to_talk_key: "ControlRight", }; async function invoke(cmd: string, args?: Record): Promise {