init attempt at new kb (#580)

* init attempt at new kb

* 0.1.1 handy-keys

* format

* cleanup

* support for capslock/shiftlock?

* move kb to experimental

* use hook

* fix macos bug

* attempt mouse buttons as well
This commit is contained in:
CJ Pais 2026-01-20 08:23:35 +08:00 committed by GitHub
parent 246fb4e7da
commit b733ccc458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1916 additions and 317 deletions

244
src-tauri/Cargo.lock generated
View file

@ -837,6 +837,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 = "cocoa"
version = "0.24.1"
@ -908,13 +923,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",
]
@ -924,16 +949,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.22.3"
@ -1584,6 +1639,29 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "evdev-rs"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b92abc30d5fd1e4f6440dee4d626abc68f4a9b5014dc1de575901e23c2e02321"
dependencies = [
"bitflags 1.3.2",
"evdev-sys",
"libc",
"log",
]
[[package]]
name = "evdev-sys"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcf0d489f4d9a80ac2b3b35b92fdd8fcf68d33bb67f947afe5cd36e482de576"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@ -2303,11 +2381,12 @@ dependencies = [
"ferrous-opencc",
"flate2",
"futures-util",
"handy-keys",
"hound",
"log",
"natural",
"once_cell",
"rdev",
"rdev 0.5.0-2",
"regex",
"reqwest",
"rodio",
@ -2344,6 +2423,25 @@ dependencies = [
"windows 0.61.3",
]
[[package]]
name = "handy-keys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10f85c247daebc113bb7128afa9ee19eb83b9a6d1301772b89111328ab856186"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"rdev 0.5.3",
"serde",
"thiserror 2.0.17",
"windows 0.58.0",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -2568,7 +2666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"core-foundation-sys 0.8.7",
"iana-time-zone-haiku",
"js-sys",
"log",
@ -2760,6 +2858,17 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46dd0a94b393c730779ccfd2a872b67b1eb67be3fc33082e733bdb38b5fde4d4"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify"
version = "0.10.2"
@ -3095,7 +3204,7 @@ 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]]
@ -3597,7 +3706,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.3",
]
@ -3608,10 +3719,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.3",
"objc2-core-foundation",
"objc2-io-surface",
"objc2-metal 0.3.2",
]
[[package]]
@ -3722,6 +3836,17 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-metal"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
@ -3744,7 +3869,7 @@ dependencies = [
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
"objc2-metal 0.2.2",
]
[[package]]
@ -4643,14 +4768,14 @@ name = "rdev"
version = "0.5.0-2"
source = "git+https://github.com/rustdesk-org/rdev#a90dbe1172f8832f54c97c62e823c5a34af5fdfe"
dependencies = [
"cocoa",
"cocoa 0.24.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"core-foundation-sys 0.8.7",
"core-graphics 0.22.3",
"dispatch",
"enum-map",
"epoll",
"inotify",
"inotify 0.10.2",
"lazy_static",
"libc",
"log",
@ -4662,6 +4787,25 @@ dependencies = [
"x11",
]
[[package]]
name = "rdev"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3"
dependencies = [
"cocoa 0.22.0",
"core-foundation 0.7.0",
"core-foundation-sys 0.7.0",
"core-graphics 0.19.2",
"epoll",
"evdev-rs",
"inotify 0.8.3",
"lazy_static",
"libc",
"winapi",
"x11",
]
[[package]]
name = "realfft"
version = "3.5.0"
@ -5135,7 +5279,7 @@ checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"core-foundation-sys 0.8.7",
"libc",
"security-framework-sys",
]
@ -5146,7 +5290,7 @@ version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"core-foundation-sys 0.8.7",
"libc",
]
@ -5839,7 +5983,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"core-foundation-sys 0.8.7",
"libc",
]
@ -7417,8 +7561,8 @@ dependencies = [
"webview2-com-sys",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@ -7544,6 +7688,16 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@ -7576,14 +7730,27 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@ -7595,8 +7762,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@ -7613,6 +7780,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@ -7624,6 +7802,17 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@ -7677,6 +7866,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@ -7695,6 +7893,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"

View file

@ -69,6 +69,7 @@ rusqlite = { version = "0.37", features = ["bundled"] }
tar = "0.4.44"
flate2 = "1.0"
transcribe-rs = { version = "0.2.2", features = ["whisper", "parakeet", "moonshine"] }
handy-keys = "0.1.4"
ferrous-opencc = "0.2.3"
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"

View file

@ -269,6 +269,10 @@ pub fn run() {
shortcut::change_append_trailing_space_setting,
shortcut::change_app_language_setting,
shortcut::change_update_checks_setting,
shortcut::change_keyboard_implementation_setting,
shortcut::get_keyboard_implementation,
shortcut::handy_keys::start_handy_keys_recording,
shortcut::handy_keys::stop_handy_keys_recording,
trigger_update_check,
commands::cancel_operation,
commands::get_app_dir_path,

View file

@ -151,6 +151,24 @@ pub enum RecordingRetentionPeriod {
Months3,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum KeyboardImplementation {
Tauri,
HandyKeys,
}
impl Default for KeyboardImplementation {
fn default() -> Self {
// Default to HandyKeys only on macOS where it's well-tested.
// Windows and Linux use Tauri by default (handy-keys not sufficiently tested yet).
#[cfg(target_os = "macos")]
return KeyboardImplementation::HandyKeys;
#[cfg(not(target_os = "macos"))]
return KeyboardImplementation::Tauri;
}
}
impl Default for ModelUnloadTimeout {
fn default() -> Self {
ModelUnloadTimeout::Never
@ -295,6 +313,8 @@ pub struct AppSettings {
pub app_language: String,
#[serde(default)]
pub experimental_enabled: bool,
#[serde(default)]
pub keyboard_implementation: KeyboardImplementation,
}
fn default_model() -> String {
@ -584,6 +604,7 @@ pub fn get_default_settings() -> AppSettings {
append_trailing_space: false,
app_language: default_app_language(),
experimental_enabled: false,
keyboard_implementation: KeyboardImplementation::default(),
}
}

View file

@ -0,0 +1,91 @@
//! Shared shortcut event handling logic
//!
//! This module contains the common logic for handling shortcut events,
//! used by both the Tauri and handy-keys implementations.
use log::warn;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use crate::settings::get_settings;
use crate::ManagedToggleState;
/// Handle a shortcut event from either implementation.
///
/// This function contains the shared logic for:
/// - Looking up the action in ACTION_MAP
/// - Handling the cancel binding (only fires when recording)
/// - Handling push-to-talk mode (start on press, stop on release)
/// - Handling toggle mode (toggle state on press only)
///
/// # Arguments
/// * `app` - The Tauri app handle
/// * `binding_id` - The ID of the binding (e.g., "transcribe", "cancel")
/// * `hotkey_string` - The string representation of the hotkey
/// * `is_pressed` - Whether this is a key press (true) or release (false)
pub fn handle_shortcut_event(
app: &AppHandle,
binding_id: &str,
hotkey_string: &str,
is_pressed: bool,
) {
let settings = get_settings(app);
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!(
"No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', Pressed: {}",
binding_id, hotkey_string, is_pressed
);
return;
};
// Cancel binding: only fires when recording and key is pressed
if binding_id == "cancel" {
let audio_manager = app.state::<Arc<AudioRecordingManager>>();
if audio_manager.is_recording() && is_pressed {
action.start(app, binding_id, hotkey_string);
}
return;
}
// Push-to-talk mode: start on press, stop on release
if settings.push_to_talk {
if is_pressed {
action.start(app, binding_id, hotkey_string);
} else {
action.stop(app, binding_id, hotkey_string);
}
return;
}
// Toggle mode: toggle state on press only
if is_pressed {
// Determine action and update state while holding the lock,
// but RELEASE the lock before calling the action to avoid deadlocks.
// (Actions may need to acquire the lock themselves, e.g., cancel_current_operation)
let should_start: bool;
{
let toggle_state_manager = app.state::<ManagedToggleState>();
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
let is_currently_active = states
.active_toggles
.entry(binding_id.to_string())
.or_insert(false);
should_start = !*is_currently_active;
*is_currently_active = should_start;
} // Lock released here
// Now call the action without holding the lock
if should_start {
action.start(app, binding_id, hotkey_string);
} else {
action.stop(app, binding_id, hotkey_string);
}
}
}

View file

@ -0,0 +1,545 @@
//! Handy-keys based keyboard shortcut implementation
//!
//! This module provides an alternative to Tauri's global-shortcut plugin
//! using the handy-keys library for more control over keyboard events.
//!
//! ## Architecture
//!
//! The implementation uses a dedicated manager thread that owns the `HotkeyManager`:
//!
//! ```text
//! ┌─────────────────┐ commands ┌──────────────────────┐
//! │ Main Thread │ ───────────────▶ │ Manager Thread │
//! │ │ (via channel) │ │
//! │ - register() │ │ - owns HotkeyManager │
//! │ - unregister() │ │ - polls for events │
//! └─────────────────┘ │ - dispatches actions │
//! └──────────────────────┘
//! ```
//!
//! This design ensures thread-safety since `HotkeyManager` is only accessed
//! from a single thread. Commands (register/unregister) are sent via an mpsc
//! channel and responses are synchronously awaited.
//!
//! ## Recording Mode
//!
//! For UI key capture, a separate `KeyboardListener` is created on-demand and
//! polled from a dedicated recording thread. Events are emitted to the frontend
//! via Tauri's event system.
use handy_keys::{Hotkey, HotkeyId, HotkeyManager, HotkeyState, KeyboardListener};
use log::{debug, error, info};
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use tauri::{AppHandle, Emitter, Manager};
use crate::settings::{self, get_settings, ShortcutBinding};
use super::handler::handle_shortcut_event;
/// Commands that can be sent to the hotkey manager thread
enum ManagerCommand {
Register {
binding_id: String,
hotkey_string: String,
response: Sender<Result<(), String>>,
},
Unregister {
binding_id: String,
response: Sender<Result<(), String>>,
},
Shutdown,
}
/// State for the handy-keys shortcut manager
pub struct HandyKeysState {
/// Channel to send commands to the manager thread (wrapped in Mutex for Sync)
command_sender: Mutex<Sender<ManagerCommand>>,
/// Handle to the manager thread (wrapped in Mutex for Sync, allows proper join on drop)
thread_handle: Mutex<Option<JoinHandle<()>>>,
/// Recording listener for UI key capture (only active during recording)
recording_listener: Mutex<Option<KeyboardListener>>,
/// Flag indicating if we're in recording mode
is_recording: AtomicBool,
/// The binding ID being recorded (if any)
recording_binding_id: Mutex<Option<String>>,
/// Flag to stop recording loop
recording_running: Arc<AtomicBool>,
}
/// Key event sent to frontend during recording mode
#[derive(Debug, Clone, Serialize, Type)]
pub struct FrontendKeyEvent {
/// Currently pressed modifier keys
pub modifiers: Vec<String>,
/// The key that was pressed (if any)
pub key: Option<String>,
/// Whether this is a key down event
pub is_key_down: bool,
/// The full hotkey string (e.g., "option+space")
pub hotkey_string: String,
}
impl HandyKeysState {
/// Create a new HandyKeysState
pub fn new(app: AppHandle) -> Result<Self, String> {
let (cmd_tx, cmd_rx) = mpsc::channel::<ManagerCommand>();
// Start the manager thread
let app_clone = app.clone();
let thread_handle = thread::spawn(move || {
Self::manager_thread(cmd_rx, app_clone);
});
Ok(Self {
command_sender: Mutex::new(cmd_tx),
thread_handle: Mutex::new(Some(thread_handle)),
recording_listener: Mutex::new(None),
is_recording: AtomicBool::new(false),
recording_binding_id: Mutex::new(None),
recording_running: Arc::new(AtomicBool::new(false)),
})
}
/// The main manager thread - owns the HotkeyManager and processes commands
fn manager_thread(cmd_rx: Receiver<ManagerCommand>, app: AppHandle) {
info!("handy-keys manager thread started");
// Create the HotkeyManager in this thread
let manager = match HotkeyManager::new() {
Ok(m) => m,
Err(e) => {
error!("Failed to create HotkeyManager: {}", e);
return;
}
};
// Maps binding IDs to HotkeyIds and hotkey strings
let mut binding_to_hotkey: HashMap<String, HotkeyId> = HashMap::new();
let mut hotkey_to_binding: HashMap<HotkeyId, (String, String)> = HashMap::new(); // (binding_id, hotkey_string)
loop {
// Check for hotkey events (non-blocking)
while let Some(event) = manager.try_recv() {
if let Some((binding_id, hotkey_string)) = hotkey_to_binding.get(&event.id) {
debug!(
"handy-keys event: binding={}, hotkey={}, state={:?}",
binding_id, hotkey_string, event.state
);
let is_pressed = event.state == HotkeyState::Pressed;
handle_shortcut_event(&app, binding_id, hotkey_string, is_pressed);
}
}
// Check for commands (non-blocking with timeout)
match cmd_rx.recv_timeout(std::time::Duration::from_millis(10)) {
Ok(cmd) => match cmd {
ManagerCommand::Register {
binding_id,
hotkey_string,
response,
} => {
let result = Self::do_register(
&manager,
&mut binding_to_hotkey,
&mut hotkey_to_binding,
&binding_id,
&hotkey_string,
);
let _ = response.send(result);
}
ManagerCommand::Unregister {
binding_id,
response,
} => {
let result = Self::do_unregister(
&manager,
&mut binding_to_hotkey,
&mut hotkey_to_binding,
&binding_id,
);
let _ = response.send(result);
}
ManagerCommand::Shutdown => {
info!("handy-keys manager thread shutting down");
break;
}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
// No command, continue
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
info!("Command channel disconnected, shutting down");
break;
}
}
}
info!("handy-keys manager thread stopped");
}
/// Register a hotkey
fn do_register(
manager: &HotkeyManager,
binding_to_hotkey: &mut HashMap<String, HotkeyId>,
hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,
binding_id: &str,
hotkey_string: &str,
) -> Result<(), String> {
let hotkey: Hotkey = hotkey_string
.parse()
.map_err(|e| format!("Failed to parse hotkey '{}': {}", hotkey_string, e))?;
let id = manager
.register(hotkey)
.map_err(|e| format!("Failed to register hotkey: {}", e))?;
binding_to_hotkey.insert(binding_id.to_string(), id);
hotkey_to_binding.insert(id, (binding_id.to_string(), hotkey_string.to_string()));
debug!(
"Registered handy-keys shortcut: {} -> {:?}",
binding_id, hotkey
);
Ok(())
}
/// Unregister a hotkey
fn do_unregister(
manager: &HotkeyManager,
binding_to_hotkey: &mut HashMap<String, HotkeyId>,
hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,
binding_id: &str,
) -> Result<(), String> {
if let Some(id) = binding_to_hotkey.remove(binding_id) {
manager
.unregister(id)
.map_err(|e| format!("Failed to unregister hotkey: {}", e))?;
hotkey_to_binding.remove(&id);
debug!("Unregistered handy-keys shortcut: {}", binding_id);
}
Ok(())
}
/// Register a shortcut binding
pub fn register(&self, binding: &ShortcutBinding) -> Result<(), String> {
let (tx, rx) = mpsc::channel();
self.command_sender
.lock()
.map_err(|_| "Failed to lock command_sender")?
.send(ManagerCommand::Register {
binding_id: binding.id.clone(),
hotkey_string: binding.current_binding.clone(),
response: tx,
})
.map_err(|_| "Failed to send register command")?;
rx.recv()
.map_err(|_| "Failed to receive register response")?
}
/// Unregister a shortcut binding
pub fn unregister(&self, binding: &ShortcutBinding) -> Result<(), String> {
let (tx, rx) = mpsc::channel();
self.command_sender
.lock()
.map_err(|_| "Failed to lock command_sender")?
.send(ManagerCommand::Unregister {
binding_id: binding.id.clone(),
response: tx,
})
.map_err(|_| "Failed to send unregister command")?;
rx.recv()
.map_err(|_| "Failed to receive unregister response")?
}
/// Start recording mode for a specific binding
pub fn start_recording(&self, app: &AppHandle, binding_id: String) -> Result<(), String> {
if self.is_recording.load(Ordering::SeqCst) {
return Err("Already recording".into());
}
// Create a new keyboard listener for recording
let listener = KeyboardListener::new()
.map_err(|e| format!("Failed to create keyboard listener: {}", e))?;
{
let mut recording = self
.recording_listener
.lock()
.map_err(|_| "Failed to lock recording_listener")?;
*recording = Some(listener);
}
{
let mut binding = self
.recording_binding_id
.lock()
.map_err(|_| "Failed to lock recording_binding_id")?;
*binding = Some(binding_id);
}
self.is_recording.store(true, Ordering::SeqCst);
self.recording_running.store(true, Ordering::SeqCst);
// Start a thread to emit key events to the frontend
let app_clone = app.clone();
let recording_running = Arc::clone(&self.recording_running);
thread::spawn(move || {
Self::recording_loop(app_clone, recording_running);
});
debug!("Started handy-keys recording mode");
Ok(())
}
/// Recording loop - emits key events to frontend during recording
fn recording_loop(app: AppHandle, running: Arc<AtomicBool>) {
while running.load(Ordering::SeqCst) {
let event = {
let state = match app.try_state::<HandyKeysState>() {
Some(s) => s,
None => break,
};
let listener = state.recording_listener.lock().ok();
listener.as_ref().and_then(|l| l.as_ref()?.try_recv())
};
if let Some(key_event) = event {
// Convert to frontend-friendly format
let frontend_event = FrontendKeyEvent {
modifiers: modifiers_to_strings(key_event.modifiers),
key: key_event.key.map(|k| k.to_string().to_lowercase()),
is_key_down: key_event.is_key_down,
hotkey_string: key_event
.as_hotkey()
.map(|h| h.to_handy_string())
.unwrap_or_default(),
};
// Emit to frontend
if let Err(e) = app.emit("handy-keys-event", &frontend_event) {
error!("Failed to emit key event: {}", e);
}
} else {
thread::sleep(std::time::Duration::from_millis(10));
}
}
debug!("Recording loop ended");
}
/// Stop recording mode
pub fn stop_recording(&self) -> Result<(), String> {
self.is_recording.store(false, Ordering::SeqCst);
self.recording_running.store(false, Ordering::SeqCst);
{
let mut recording = self
.recording_listener
.lock()
.map_err(|_| "Failed to lock recording_listener")?;
*recording = None;
}
{
let mut binding = self
.recording_binding_id
.lock()
.map_err(|_| "Failed to lock recording_binding_id")?;
*binding = None;
}
debug!("Stopped handy-keys recording mode");
Ok(())
}
}
impl Drop for HandyKeysState {
fn drop(&mut self) {
// Signal recording to stop
self.recording_running.store(false, Ordering::SeqCst);
self.is_recording.store(false, Ordering::SeqCst);
// Send shutdown command
if let Ok(sender) = self.command_sender.lock() {
let _ = sender.send(ManagerCommand::Shutdown);
}
// Wait for the manager thread to finish
if let Ok(mut handle) = self.thread_handle.lock() {
if let Some(h) = handle.take() {
let _ = h.join();
}
}
}
}
/// Convert handy-keys Modifiers to a list of strings
fn modifiers_to_strings(modifiers: handy_keys::Modifiers) -> Vec<String> {
let mut result = Vec::new();
if modifiers.contains(handy_keys::Modifiers::CTRL) {
result.push("ctrl".to_string());
}
if modifiers.contains(handy_keys::Modifiers::OPT) {
#[cfg(target_os = "macos")]
result.push("option".to_string());
#[cfg(not(target_os = "macos"))]
result.push("alt".to_string());
}
if modifiers.contains(handy_keys::Modifiers::SHIFT) {
result.push("shift".to_string());
}
if modifiers.contains(handy_keys::Modifiers::CMD) {
#[cfg(target_os = "macos")]
result.push("command".to_string());
#[cfg(not(target_os = "macos"))]
result.push("super".to_string());
}
if modifiers.contains(handy_keys::Modifiers::FN) {
result.push("fn".to_string());
}
result
}
/// Validate a shortcut string for the HandyKeys implementation.
/// HandyKeys is more permissive: allows modifier-only combos and the fn key.
pub fn validate_shortcut(raw: &str) -> Result<(), String> {
if raw.trim().is_empty() {
return Err("Shortcut cannot be empty".into());
}
// HandyKeys accepts modifier-only, key-only, and modifier+key combos
// Just verify the string is parseable
raw.parse::<Hotkey>()
.map(|_| ())
.map_err(|e| format!("Invalid shortcut for HandyKeys: {}", e))
}
/// Initialize handy-keys shortcuts
pub fn init_shortcuts(app: &AppHandle) -> Result<(), String> {
let state = HandyKeysState::new(app.clone())?;
let default_bindings = settings::get_default_settings().bindings;
let user_settings = settings::load_or_create_app_settings(app);
// Register all bindings except cancel (which is dynamic)
for (id, default_binding) in default_bindings {
if id == "cancel" {
continue;
}
let binding = user_settings
.bindings
.get(&id)
.cloned()
.unwrap_or(default_binding);
if let Err(e) = state.register(&binding) {
error!(
"Failed to register handy-keys shortcut {} during init: {}",
id, e
);
}
}
app.manage(state);
info!("handy-keys shortcuts initialized");
Ok(())
}
/// Register the cancel shortcut (called when recording starts)
pub fn register_cancel_shortcut(app: &AppHandle) {
// Disabled on Linux due to instability
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
if let Some(state) = app_clone.try_state::<HandyKeysState>() {
if let Err(e) = state.register(&cancel_binding) {
error!("Failed to register cancel shortcut: {}", e);
}
}
}
});
}
}
/// Unregister the cancel shortcut (called when recording stops)
pub fn unregister_cancel_shortcut(app: &AppHandle) {
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
if let Some(state) = app_clone.try_state::<HandyKeysState>() {
let _ = state.unregister(&cancel_binding);
}
}
});
}
}
/// Register a shortcut
pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let state = app
.try_state::<HandyKeysState>()
.ok_or("HandyKeysState not initialized")?;
state.register(&binding)
}
/// Unregister a shortcut
pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let state = app
.try_state::<HandyKeysState>()
.ok_or("HandyKeysState not initialized")?;
state.unregister(&binding)
}
/// Start key recording mode
#[tauri::command]
#[specta::specta]
pub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result<(), String> {
let settings = get_settings(&app);
if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys {
return Err("handy-keys is not the active keyboard implementation".into());
}
let state = app
.try_state::<HandyKeysState>()
.ok_or("HandyKeysState not initialized")?;
state.start_recording(&app, binding_id)
}
/// Stop key recording mode
#[tauri::command]
#[specta::specta]
pub fn stop_handy_keys_recording(app: AppHandle) -> Result<(), String> {
let settings = get_settings(&app);
if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys {
return Err("handy-keys is not the active keyboard implementation".into());
}
let state = app
.try_state::<HandyKeysState>()
.ok_or("HandyKeysState not initialized")?;
state.stop_recording()
}

View file

@ -1,42 +1,99 @@
use log::{error, warn};
//! Keyboard shortcut management module
//!
//! This module provides a unified interface for keyboard shortcuts with
//! multiple backend implementations:
//!
//! - `tauri`: Uses Tauri's built-in global-shortcut plugin
//! - `handy_keys`: Uses the handy-keys library for more control
//!
//! The active implementation is determined by the `keyboard_implementation`
//! setting and can be changed at runtime.
mod handler;
pub mod handy_keys;
mod tauri_impl;
use log::{error, info, warn};
use serde::Serialize;
use specta::Type;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_autostart::ManagerExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use crate::settings::ShortcutBinding;
use crate::settings::{
self, get_settings, ClipboardHandling, LLMPrompt, OverlayPosition, PasteMethod, SoundTheme,
APPLE_INTELLIGENCE_DEFAULT_MODEL_ID, APPLE_INTELLIGENCE_PROVIDER_ID,
self, get_settings, ClipboardHandling, KeyboardImplementation, LLMPrompt, OverlayPosition,
PasteMethod, ShortcutBinding, SoundTheme, APPLE_INTELLIGENCE_DEFAULT_MODEL_ID,
APPLE_INTELLIGENCE_PROVIDER_ID,
};
use crate::tray;
use crate::ManagedToggleState;
// Note: Commands are accessed via shortcut::handy_keys:: in lib.rs
/// Initialize shortcuts using the configured implementation
pub fn init_shortcuts(app: &AppHandle) {
let default_bindings = settings::get_default_settings().bindings;
let user_settings = settings::load_or_create_app_settings(app);
// Register all default shortcuts, applying user customizations
for (id, default_binding) in default_bindings {
if id == "cancel" {
continue; // Skip cancel shortcut, it will be registered dynamically
// Check which implementation to use
match user_settings.keyboard_implementation {
KeyboardImplementation::Tauri => {
tauri_impl::init_shortcuts(app);
}
let binding = user_settings
.bindings
.get(&id)
.cloned()
.unwrap_or(default_binding);
KeyboardImplementation::HandyKeys => {
if let Err(e) = handy_keys::init_shortcuts(app) {
error!("Failed to initialize handy-keys shortcuts: {}", e);
// Fall back to Tauri implementation and persist this fallback
warn!("Falling back to Tauri global shortcut implementation and saving fallback to settings");
if let Err(e) = register_shortcut(app, binding) {
error!("Failed to register shortcut {} during init: {}", id, e);
// Update settings to persist the fallback so we don't retry HandyKeys on next launch
let mut settings = settings::get_settings(app);
settings.keyboard_implementation = KeyboardImplementation::Tauri;
settings::write_settings(app, settings);
tauri_impl::init_shortcuts(app);
}
}
}
}
/// Register the cancel shortcut (called when recording starts)
pub fn register_cancel_shortcut(app: &AppHandle) {
let settings = get_settings(app);
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app),
KeyboardImplementation::HandyKeys => handy_keys::register_cancel_shortcut(app),
}
}
/// Unregister the cancel shortcut (called when recording stops)
pub fn unregister_cancel_shortcut(app: &AppHandle) {
let settings = get_settings(app);
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app),
KeyboardImplementation::HandyKeys => handy_keys::unregister_cancel_shortcut(app),
}
}
/// Register a shortcut using the appropriate implementation
pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let settings = get_settings(app);
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),
}
}
/// Unregister a shortcut using the appropriate implementation
pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let settings = get_settings(app);
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),
}
}
// ============================================================================
// Binding Management Commands
// ============================================================================
#[derive(Serialize, Type)]
pub struct BindingResponse {
success: bool,
@ -66,6 +123,7 @@ pub fn change_binding(
});
}
};
// If this is the cancel binding, just update the settings and return
// It's managed dynamically, so we don't register/unregister here
if id == "cancel" {
@ -87,8 +145,9 @@ pub fn change_binding(
error!("change_binding error: {}", error_msg);
}
// Validate the new shortcut before we touch the current registration
if let Err(e) = validate_shortcut_string(&binding) {
// Validate the new shortcut for the current keyboard implementation
if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation)
{
warn!("change_binding validation error: {}", e);
return Err(e);
}
@ -126,21 +185,273 @@ pub fn change_binding(
#[specta::specta]
pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> {
let binding = settings::get_stored_binding(&app, &id);
return change_binding(app, id, binding.default_binding);
change_binding(app, id, binding.default_binding)
}
/// Temporarily unregister a binding while the user is editing it in the UI.
/// This avoids firing the action while keys are being recorded.
#[tauri::command]
#[specta::specta]
pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = unregister_shortcut(&app, b) {
error!("suspend_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
/// Re-register the binding after the user has finished editing.
#[tauri::command]
#[specta::specta]
pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = register_shortcut(&app, b) {
error!("resume_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
// ============================================================================
// Keyboard Implementation Switching
// ============================================================================
/// Result of changing keyboard implementation
#[derive(Serialize, Type)]
pub struct ImplementationChangeResult {
pub success: bool,
/// List of binding IDs that were reset to defaults due to incompatibility
pub reset_bindings: Vec<String>,
}
/// Change the keyboard implementation with runtime switching.
/// This will unregister all shortcuts from the old implementation,
/// validate shortcuts for the new implementation (resetting invalid ones to defaults),
/// and register them with the new implementation.
#[tauri::command]
#[specta::specta]
pub fn change_keyboard_implementation_setting(
app: AppHandle,
implementation: String,
) -> Result<ImplementationChangeResult, String> {
let current_settings = settings::get_settings(&app);
let current_impl = current_settings.keyboard_implementation;
let new_impl = parse_keyboard_implementation(&implementation);
// If same implementation, nothing to do
if current_impl == new_impl {
return Ok(ImplementationChangeResult {
success: true,
reset_bindings: vec![],
});
}
info!(
"Switching keyboard implementation from {:?} to {:?}",
current_impl, new_impl
);
// Unregister all shortcuts from the current implementation
unregister_all_shortcuts(&app, current_impl);
// Update the setting
let mut settings = settings::get_settings(&app);
settings.keyboard_implementation = new_impl;
settings::write_settings(&app, settings);
// Initialize new implementation if needed (HandyKeys needs state)
if new_impl == KeyboardImplementation::HandyKeys {
if initialize_handy_keys_with_rollback(&app)? {
// Shortcuts already registered during init
return Ok(ImplementationChangeResult {
success: true,
reset_bindings: vec![],
});
}
}
// Register all shortcuts with new implementation, resetting invalid ones
let reset_bindings = register_all_shortcuts_for_implementation(&app, new_impl);
// Emit event to notify frontend of the change
let _ = app.emit(
"settings-changed",
serde_json::json!({
"setting": "keyboard_implementation",
"value": implementation,
"reset_bindings": reset_bindings
}),
);
info!("Keyboard implementation switched to {:?}", new_impl);
Ok(ImplementationChangeResult {
success: true,
reset_bindings,
})
}
/// Get the current keyboard implementation
#[tauri::command]
#[specta::specta]
pub fn get_keyboard_implementation(app: AppHandle) -> String {
let settings = settings::get_settings(&app);
match settings.keyboard_implementation {
KeyboardImplementation::Tauri => "tauri".to_string(),
KeyboardImplementation::HandyKeys => "handy_keys".to_string(),
}
}
// ============================================================================
// Validation Helpers
// ============================================================================
/// Validate a shortcut for a specific implementation
fn validate_shortcut_for_implementation(
raw: &str,
implementation: KeyboardImplementation,
) -> Result<(), String> {
match implementation {
KeyboardImplementation::Tauri => tauri_impl::validate_shortcut(raw),
KeyboardImplementation::HandyKeys => handy_keys::validate_shortcut(raw),
}
}
/// Parse a keyboard implementation string into the enum
fn parse_keyboard_implementation(s: &str) -> KeyboardImplementation {
match s {
"tauri" => KeyboardImplementation::Tauri,
"handy_keys" => KeyboardImplementation::HandyKeys,
other => {
warn!(
"Invalid keyboard implementation '{}', defaulting to tauri",
other
);
KeyboardImplementation::Tauri
}
}
}
/// Unregister all shortcuts for the current implementation
fn unregister_all_shortcuts(app: &AppHandle, implementation: KeyboardImplementation) {
let bindings = settings::get_bindings(app);
for (id, binding) in bindings {
// Skip cancel shortcut as it's dynamically registered
if id == "cancel" {
continue;
}
let result = match implementation {
KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),
};
if let Err(e) = result {
warn!(
"Failed to unregister shortcut '{}' during switch: {}",
id, e
);
}
}
}
/// Register all shortcuts for a specific implementation, validating and resetting invalid ones
fn register_all_shortcuts_for_implementation(
app: &AppHandle,
implementation: KeyboardImplementation,
) -> Vec<String> {
let mut reset_bindings = Vec::new();
let default_bindings = settings::get_default_settings().bindings;
let mut current_settings = settings::get_settings(app);
for (id, default_binding) in &default_bindings {
// Skip cancel shortcut as it's dynamically registered
if id == "cancel" {
continue;
}
let mut binding = current_settings
.bindings
.get(id)
.cloned()
.unwrap_or_else(|| default_binding.clone());
// Validate the shortcut for the target implementation
if let Err(e) =
validate_shortcut_for_implementation(&binding.current_binding, implementation)
{
info!(
"Shortcut '{}' ({}) is invalid for {:?}: {}. Resetting to default.",
id, binding.current_binding, implementation, e
);
// Reset to default
binding.current_binding = default_binding.current_binding.clone();
current_settings
.bindings
.insert(id.clone(), binding.clone());
reset_bindings.push(id.clone());
}
// Register with the appropriate implementation
let result = match implementation {
KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),
KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),
};
if let Err(e) = result {
error!(
"Failed to register shortcut '{}' for {:?}: {}",
id, implementation, e
);
}
}
// Save settings if any bindings were reset
if !reset_bindings.is_empty() {
settings::write_settings(app, current_settings);
}
reset_bindings
}
/// Initialize HandyKeys if not already initialized, with rollback on failure
fn initialize_handy_keys_with_rollback(app: &AppHandle) -> Result<bool, String> {
if app.try_state::<handy_keys::HandyKeysState>().is_some() {
return Ok(false); // Already initialized, caller should continue
}
if let Err(e) = handy_keys::init_shortcuts(app) {
error!("Failed to initialize HandyKeys: {}", e);
// Rollback to Tauri
let mut settings = settings::get_settings(app);
settings.keyboard_implementation = KeyboardImplementation::Tauri;
settings::write_settings(app, settings);
tauri_impl::init_shortcuts(app);
return Err(format!(
"Failed to initialize HandyKeys: {}. Reverted to Tauri.",
e
));
}
// init_shortcuts already registered shortcuts
Ok(true)
}
// ============================================================================
// General Settings Commands
// ============================================================================
#[tauri::command]
#[specta::specta]
pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
// TODO if the setting is currently false, we probably want to
// cancel any ongoing recordings or actions
settings.push_to_talk = enabled;
settings::write_settings(&app, settings);
Ok(())
}
@ -606,7 +917,6 @@ pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Res
let mut settings = settings::get_settings(&app);
settings.mute_while_recording = enabled;
settings::write_settings(&app, settings);
Ok(())
}
@ -616,7 +926,6 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re
let mut settings = settings::get_settings(&app);
settings.append_trailing_space = enabled;
settings::write_settings(&app, settings);
Ok(())
}
@ -632,217 +941,3 @@ pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(
Ok(())
}
/// Validate that a shortcut contains at least one non-modifier key.
/// The tauri-plugin-global-shortcut library requires at least one main key.
fn validate_shortcut_string(raw: &str) -> Result<(), String> {
if raw.trim().is_empty() {
return Err("Shortcut cannot be empty".into());
}
let modifiers = [
"ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win",
"windows",
];
let has_non_modifier = raw
.split('+')
.any(|part| !modifiers.contains(&part.trim().to_lowercase().as_str()));
if has_non_modifier {
Ok(())
} else {
Err("Shortcut must include a main key (letter, number, F-key, etc.) in addition to modifiers".into())
}
}
/// Temporarily unregister a binding while the user is editing it in the UI.
/// This avoids firing the action while keys are being recorded.
#[tauri::command]
#[specta::specta]
pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = unregister_shortcut(&app, b) {
error!("suspend_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
/// Re-register the binding after the user has finished editing.
#[tauri::command]
#[specta::specta]
pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = register_shortcut(&app, b) {
error!("resume_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
pub fn register_cancel_shortcut(app: &AppHandle) {
// Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
if let Err(e) = register_shortcut(&app_clone, cancel_binding) {
eprintln!("Failed to register cancel shortcut: {}", e);
}
}
});
}
}
pub fn unregister_cancel_shortcut(app: &AppHandle) {
// Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
// We ignore errors here as it might already be unregistered
let _ = unregister_shortcut(&app_clone, cancel_binding);
}
});
}
}
pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
// Validate human-level rules first
if let Err(e) = validate_shortcut_string(&binding.current_binding) {
warn!(
"_register_shortcut validation error for binding '{}': {}",
binding.current_binding, e
);
return Err(e);
}
// Parse shortcut and return error if it fails
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}': {}",
binding.current_binding, e
);
error!("_register_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
// Prevent duplicate registrations that would silently shadow one another
if app.global_shortcut().is_registered(shortcut) {
let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding);
warn!("_register_shortcut duplicate error: {}", error_msg);
return Err(error_msg);
}
// Clone binding.id for use in the closure
let binding_id_for_closure = binding.id.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |ah, scut, event| {
if scut == &shortcut {
let shortcut_string = scut.into_string();
let settings = get_settings(ah);
if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) {
if binding_id_for_closure == "cancel" {
let audio_manager = ah.state::<Arc<AudioRecordingManager>>();
if audio_manager.is_recording() && event.state == ShortcutState::Pressed {
action.start(ah, &binding_id_for_closure, &shortcut_string);
}
return;
} else if settings.push_to_talk {
if event.state == ShortcutState::Pressed {
action.start(ah, &binding_id_for_closure, &shortcut_string);
} else if event.state == ShortcutState::Released {
action.stop(ah, &binding_id_for_closure, &shortcut_string);
}
} else {
// Toggle mode: toggle on press only
if event.state == ShortcutState::Pressed {
// Determine action and update state while holding the lock,
// but RELEASE the lock before calling the action to avoid deadlocks.
// (Actions may need to acquire the lock themselves, e.g., cancel_current_operation)
let should_start: bool;
{
let toggle_state_manager = ah.state::<ManagedToggleState>();
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
let is_currently_active = states
.active_toggles
.entry(binding_id_for_closure.clone())
.or_insert(false);
should_start = !*is_currently_active;
*is_currently_active = should_start;
} // Lock released here
// Now call the action without holding the lock
if should_start {
action.start(ah, &binding_id_for_closure, &shortcut_string);
} else {
action.stop(ah, &binding_id_for_closure, &shortcut_string);
}
}
}
} else {
warn!(
"No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}",
binding_id_for_closure, shortcut_string, event.state
);
}
}
})
.map_err(|e| {
let error_msg = format!("Couldn't register shortcut '{}': {}", binding.current_binding, e);
error!("_register_shortcut registration error: {}", error_msg);
error_msg
})?;
Ok(())
}
pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}' for unregistration: {}",
binding.current_binding, e
);
error!("_unregister_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
app.global_shortcut().unregister(shortcut).map_err(|e| {
let error_msg = format!(
"Failed to unregister shortcut '{}': {}",
binding.current_binding, e
);
error!("_unregister_shortcut error: {}", error_msg);
error_msg
})?;
Ok(())
}

View file

@ -0,0 +1,192 @@
//! Tauri global-shortcut implementation
//!
//! This module provides shortcut functionality using Tauri's built-in
//! global-shortcut plugin.
use log::{error, warn};
use tauri::AppHandle;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::settings::{self, get_settings, ShortcutBinding};
use super::handler::handle_shortcut_event;
/// Initialize shortcuts using Tauri's global-shortcut plugin
pub fn init_shortcuts(app: &AppHandle) {
let default_bindings = settings::get_default_settings().bindings;
let user_settings = settings::load_or_create_app_settings(app);
// Register all default shortcuts, applying user customizations
for (id, default_binding) in default_bindings {
if id == "cancel" {
continue; // Skip cancel shortcut, it will be registered dynamically
}
let binding = user_settings
.bindings
.get(&id)
.cloned()
.unwrap_or(default_binding);
if let Err(e) = register_shortcut(app, binding) {
error!("Failed to register shortcut {} during init: {}", id, e);
}
}
}
/// Validate a shortcut string for the Tauri global-shortcut implementation.
/// Tauri requires at least one non-modifier key and doesn't support the fn key.
pub fn validate_shortcut(raw: &str) -> Result<(), String> {
if raw.trim().is_empty() {
return Err("Shortcut cannot be empty".into());
}
let modifiers = [
"ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win",
"windows",
];
// Check for fn key which Tauri doesn't support
let parts: Vec<String> = raw.split('+').map(|p| p.trim().to_lowercase()).collect();
for part in &parts {
if part == "fn" || part == "function" {
return Err("The 'fn' key is not supported by Tauri global shortcuts".into());
}
}
// Check for at least one non-modifier key
let has_non_modifier = parts.iter().any(|part| !modifiers.contains(&part.as_str()));
if has_non_modifier {
Ok(())
} else {
Err("Tauri shortcuts must include a main key (letter, number, F-key, etc.) in addition to modifiers".into())
}
}
/// Register a shortcut using Tauri's global-shortcut plugin
pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
// Validate for Tauri requirements
if let Err(e) = validate_shortcut(&binding.current_binding) {
warn!(
"register_tauri_shortcut validation error for binding '{}': {}",
binding.current_binding, e
);
return Err(e);
}
// Parse shortcut and return error if it fails
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}': {}",
binding.current_binding, e
);
error!("register_tauri_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
// Prevent duplicate registrations that would silently shadow one another
if app.global_shortcut().is_registered(shortcut) {
let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding);
warn!("register_tauri_shortcut duplicate error: {}", error_msg);
return Err(error_msg);
}
// Clone binding.id for use in the closure
let binding_id_for_closure = binding.id.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |app_handle, scut, event| {
if scut == &shortcut {
let shortcut_string = scut.into_string();
let is_pressed = event.state == ShortcutState::Pressed;
handle_shortcut_event(
app_handle,
&binding_id_for_closure,
&shortcut_string,
is_pressed,
);
}
})
.map_err(|e| {
let error_msg = format!(
"Couldn't register shortcut '{}': {}",
binding.current_binding, e
);
error!("register_tauri_shortcut registration error: {}", error_msg);
error_msg
})?;
Ok(())
}
/// Unregister a shortcut from Tauri's global-shortcut plugin
pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}' for unregistration: {}",
binding.current_binding, e
);
error!("unregister_tauri_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
app.global_shortcut().unregister(shortcut).map_err(|e| {
let error_msg = format!(
"Failed to unregister shortcut '{}': {}",
binding.current_binding, e
);
error!("unregister_tauri_shortcut error: {}", error_msg);
error_msg
})?;
Ok(())
}
/// Register the cancel shortcut (called when recording starts)
pub fn register_cancel_shortcut(app: &AppHandle) {
// Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
if let Err(e) = register_shortcut(&app_clone, cancel_binding) {
error!("Failed to register cancel shortcut: {}", e);
}
}
});
}
}
/// Unregister the cancel shortcut (called when recording stops)
pub fn unregister_cancel_shortcut(app: &AppHandle) {
// Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration
#[cfg(target_os = "linux")]
{
let _ = app;
return;
}
#[cfg(not(target_os = "linux"))]
{
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(cancel_binding) = get_settings(&app_clone).bindings.get("cancel").cloned() {
// We ignore errors here as it might already be unregistered
let _ = unregister_shortcut(&app_clone, cancel_binding);
}
});
}
}

View file

@ -276,6 +276,48 @@ async changeUpdateChecksSetting(enabled: boolean) : Promise<Result<null, string>
else return { status: "error", error: e as any };
}
},
/**
* Change the keyboard implementation with runtime switching.
* This will unregister all shortcuts from the old implementation,
* validate shortcuts for the new implementation (resetting invalid ones to defaults),
* and register them with the new implementation.
*/
async changeKeyboardImplementationSetting(implementation: string) : Promise<Result<ImplementationChangeResult, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_keyboard_implementation_setting", { implementation }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Get the current keyboard implementation
*/
async getKeyboardImplementation() : Promise<string> {
return await TAURI_INVOKE("get_keyboard_implementation");
},
/**
* Start key recording mode
*/
async startHandyKeysRecording(bindingId: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("start_handy_keys_recording", { bindingId }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
/**
* Stop key recording mode
*/
async stopHandyKeysRecording() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("stop_handy_keys_recording") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async triggerUpdateCheck() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") };
@ -648,13 +690,22 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** user-defined types **/
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean }
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; keyboard_implementation?: KeyboardImplementation }
export type AudioDevice = { index: string; name: string; is_default: boolean }
export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }
export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
export type CustomSounds = { start: boolean; stop: boolean }
export type EngineType = "Whisper" | "Parakeet" | "Moonshine"
export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
/**
* Result of changing keyboard implementation
*/
export type ImplementationChangeResult = { success: boolean;
/**
* List of binding IDs that were reset to defaults due to incompatibility
*/
reset_bindings: string[] }
export type KeyboardImplementation = "tauri" | "handy_keys"
export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number }

View file

@ -1,26 +1,25 @@
import React, { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { type } from "@tauri-apps/plugin-os";
import {
getKeyName,
formatKeyCombination,
normalizeKey,
type OSType,
} from "../../lib/utils/keyboard";
import { ResetButton } from "../ui/ResetButton";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { useOsType } from "../../hooks/useOsType";
import { commands } from "@/bindings";
import { toast } from "sonner";
interface HandyShortcutProps {
interface GlobalShortcutInputProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
shortcutId: string;
disabled?: boolean;
}
export const HandyShortcut: React.FC<HandyShortcutProps> = ({
export const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({
descriptionMode = "tooltip",
grouped = false,
shortcutId,
@ -35,42 +34,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
null,
);
const [originalBinding, setOriginalBinding] = useState<string>("");
const [osType, setOsType] = useState<OSType>("unknown");
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
const osType = useOsType();
const bindings = getSetting("bindings") || {};
// Detect and store OS type
useEffect(() => {
const detectOsType = async () => {
try {
const detectedType = type();
let normalizedType: OSType;
switch (detectedType) {
case "macos":
normalizedType = "macos";
break;
case "windows":
normalizedType = "windows";
break;
case "linux":
normalizedType = "linux";
break;
default:
normalizedType = "unknown";
}
setOsType(normalizedType);
} catch (error) {
console.error("Error detecting OS type:", error);
setOsType("unknown");
}
};
detectOsType();
}, []);
useEffect(() => {
// Only add event listeners when we're in editing mode
if (editingShortcutId === null) return;

View file

@ -0,0 +1,300 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { formatKeyCombination } from "../../lib/utils/keyboard";
import { ResetButton } from "../ui/ResetButton";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { useOsType } from "../../hooks/useOsType";
import { commands } from "@/bindings";
import { toast } from "sonner";
interface HandyKeysShortcutInputProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
shortcutId: string;
disabled?: boolean;
}
interface HandyKeysEvent {
modifiers: string[];
key: string | null;
is_key_down: boolean;
hotkey_string: string;
}
export const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({
descriptionMode = "tooltip",
grouped = false,
shortcutId,
disabled = false,
}) => {
const { t } = useTranslation();
const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =
useSettings();
const [isRecording, setIsRecording] = useState(false);
const [currentKeys, setCurrentKeys] = useState<string>("");
const [originalBinding, setOriginalBinding] = useState<string>("");
const shortcutRef = useRef<HTMLDivElement | null>(null);
const unlistenRef = useRef<(() => void) | null>(null);
// Use a ref to track currentKeys for the event handler (avoids stale closure)
const currentKeysRef = useRef<string>("");
const osType = useOsType();
const bindings = getSetting("bindings") || {};
// Handle cancellation
const cancelRecording = useCallback(async () => {
if (!isRecording) return;
// Stop listening for backend events
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
// Stop backend recording
await commands.stopHandyKeysRecording().catch(console.error);
// Restore original binding
if (originalBinding) {
try {
await updateBinding(shortcutId, originalBinding);
} catch (error) {
console.error("Failed to restore original binding:", error);
toast.error(t("settings.general.shortcut.errors.restore"));
}
}
setIsRecording(false);
setCurrentKeys("");
currentKeysRef.current = "";
setOriginalBinding("");
}, [isRecording, originalBinding, shortcutId, updateBinding, t]);
// Set up event listener for handy-keys events
useEffect(() => {
if (!isRecording) return;
let cleanup = false;
const setupListener = async () => {
// Listen for key events from backend
const unlisten = await listen<HandyKeysEvent>(
"handy-keys-event",
async (event) => {
if (cleanup) return;
const { hotkey_string, is_key_down } = event.payload;
if (is_key_down && hotkey_string) {
// Update both state (for display) and ref (for release handler)
currentKeysRef.current = hotkey_string;
setCurrentKeys(hotkey_string);
} else if (!is_key_down && currentKeysRef.current) {
// Key released - commit the shortcut using the ref value
const keysToCommit = currentKeysRef.current;
try {
await updateBinding(shortcutId, keysToCommit);
} catch (error) {
console.error("Failed to change binding:", error);
toast.error(
t("settings.general.shortcut.errors.set", {
error: String(error),
}),
);
// Reset to original binding on error
if (originalBinding) {
try {
await updateBinding(shortcutId, originalBinding);
} catch (resetError) {
console.error("Failed to reset binding:", resetError);
toast.error(t("settings.general.shortcut.errors.reset"));
}
}
}
// Stop recording
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
await commands.stopHandyKeysRecording().catch(console.error);
setIsRecording(false);
setCurrentKeys("");
currentKeysRef.current = "";
setOriginalBinding("");
}
},
);
unlistenRef.current = unlisten;
};
setupListener();
// Handle escape key to cancel
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
cancelRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
cleanup = true;
window.removeEventListener("keydown", handleKeyDown);
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
// Stop backend recording on unmount to prevent orphaned recording loops
commands.stopHandyKeysRecording().catch(console.error);
};
}, [
isRecording,
shortcutId,
originalBinding,
updateBinding,
cancelRecording,
t,
]);
// Handle click outside
useEffect(() => {
if (!isRecording) return;
const handleClickOutside = (e: MouseEvent) => {
if (
shortcutRef.current &&
!shortcutRef.current.contains(e.target as Node)
) {
cancelRecording();
}
};
window.addEventListener("click", handleClickOutside);
return () => window.removeEventListener("click", handleClickOutside);
}, [isRecording, cancelRecording]);
// Start recording a new shortcut
const startRecording = async () => {
if (isRecording) return;
// Store the original binding to restore if canceled
setOriginalBinding(bindings[shortcutId]?.current_binding || "");
// Start backend recording
try {
await commands.startHandyKeysRecording(shortcutId);
setIsRecording(true);
setCurrentKeys("");
currentKeysRef.current = "";
} catch (error) {
console.error("Failed to start recording:", error);
toast.error(
t("settings.general.shortcut.errors.set", { error: String(error) }),
);
}
};
// Format the current shortcut keys being recorded
const formatCurrentKeys = (): string => {
if (!currentKeys) return t("settings.general.shortcut.pressKeys");
return formatKeyCombination(currentKeys, osType);
};
// If still loading, show loading state
if (isLoading) {
return (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.loading")}
</div>
</SettingContainer>
);
}
// If no bindings are loaded, show empty state
if (Object.keys(bindings).length === 0) {
return (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
const binding = bindings[shortcutId];
if (!binding) {
return (
<SettingContainer
title={t("settings.general.shortcut.title")}
description={t("settings.general.shortcut.notFound")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-mid-gray">
{t("settings.general.shortcut.none")}
</div>
</SettingContainer>
);
}
// Get translated name and description for the binding
const translatedName = t(
`settings.general.shortcut.bindings.${shortcutId}.name`,
binding.name,
);
const translatedDescription = t(
`settings.general.shortcut.bindings.${shortcutId}.description`,
binding.description,
);
return (
<SettingContainer
title={translatedName}
description={translatedDescription}
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}
layout="horizontal"
>
<div className="flex items-center space-x-1">
{isRecording ? (
<div
ref={shortcutRef}
className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center"
>
{formatCurrentKeys()}
</div>
) : (
<div
className="px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary"
onClick={startRecording}
>
{formatKeyCombination(binding.current_binding, osType)}
</div>
)}
<ResetButton
onClick={() => resetBinding(shortcutId)}
disabled={isUpdating(`binding_${shortcutId}`)}
/>
</div>
</SettingContainer>
);
};

View file

@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { type as getOsType } from "@tauri-apps/plugin-os";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
import { useSettings } from "../../hooks/useSettings";
import { useOsType } from "../../hooks/useOsType";
import type { PasteMethod } from "@/bindings";
interface PasteMethodProps {
@ -15,7 +15,7 @@ export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
const [osType, setOsType] = useState<string>("unknown");
const osType = useOsType();
const getPasteMethodOptions = (osType: string) => {
const mod = osType === "macos" ? "Cmd" : "Ctrl";
@ -58,10 +58,6 @@ export const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(
return options;
};
useEffect(() => {
setOsType(getOsType());
}, []);
const selectedMethod = (getSetting("paste_method") ||
"ctrl_v") as PasteMethod;

View file

@ -0,0 +1,30 @@
import React from "react";
import { useSettings } from "../../hooks/useSettings";
import { GlobalShortcutInput } from "./GlobalShortcutInput";
import { HandyKeysShortcutInput } from "./HandyKeysShortcutInput";
interface ShortcutInputProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
shortcutId: string;
disabled?: boolean;
}
/**
* Wrapper component that selects the appropriate shortcut input implementation
* based on the keyboard_implementation setting.
*
* - "tauri" (default): Uses GlobalShortcutInput with JS keyboard events
* - "handy_keys": Uses HandyKeysShortcutInput with backend key events
*/
export const ShortcutInput: React.FC<ShortcutInputProps> = (props) => {
const { getSetting } = useSettings();
const keyboardImplementation = getSetting("keyboard_implementation");
// Default to Tauri implementation if not set
if (keyboardImplementation === "handy_keys") {
return <HandyKeysShortcutInput {...props} />;
}
return <GlobalShortcutInput {...props} />;
};

View file

@ -16,6 +16,7 @@ import { HistoryLimit } from "../HistoryLimit";
import { RecordingRetentionPeriodSelector } from "../RecordingRetentionPeriod";
import { ExperimentalToggle } from "../ExperimentalToggle";
import { useSettings } from "../../../hooks/useSettings";
import { KeyboardImplementationSelector } from "../debug/KeyboardImplementationSelector";
export const AdvancedSettings: React.FC = () => {
const { t } = useTranslation();
@ -60,6 +61,10 @@ export const AdvancedSettings: React.FC = () => {
{experimentalEnabled && (
<SettingsGroup title={t("settings.advanced.groups.experimental")}>
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />
<KeyboardImplementationSelector
descriptionMode="tooltip"
grouped={true}
/>
</SettingsGroup>
)}
</div>

View file

@ -7,7 +7,7 @@ import { SettingsGroup } from "../../ui/SettingsGroup";
import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone";
import { SoundPicker } from "../SoundPicker";
import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector";
import { HandyShortcut } from "../HandyShortcut";
import { ShortcutInput } from "../ShortcutInput";
import { UpdateChecksToggle } from "../UpdateChecksToggle";
import { useSettings } from "../../../hooks/useSettings";
@ -31,7 +31,7 @@ export const DebugSettings: React.FC = () => {
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
{/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */}
{!isLinux && (
<HandyShortcut
<ShortcutInput
shortcutId="cancel"
grouped={true}
disabled={pushToTalk}

View file

@ -0,0 +1,70 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingContainer } from "../../ui/SettingContainer";
import { Dropdown, type DropdownOption } from "../../ui/Dropdown";
import { useSettings } from "../../../hooks/useSettings";
import { commands } from "@/bindings";
import { toast } from "sonner";
const KEYBOARD_IMPLEMENTATION_OPTIONS: DropdownOption[] = [
{ value: "tauri", label: "Tauri Global Shortcut" },
{ value: "handy_keys", label: "Handy Keys" },
];
interface KeyboardImplementationSelectorProps {
descriptionMode?: "tooltip" | "inline";
grouped?: boolean;
}
export const KeyboardImplementationSelector: React.FC<
KeyboardImplementationSelectorProps
> = ({ descriptionMode = "tooltip", grouped = false }) => {
const { t } = useTranslation();
const { getSetting, isUpdating, refreshSettings } = useSettings();
const currentImplementation =
getSetting("keyboard_implementation") ?? "tauri";
const handleSelect = async (value: string) => {
if (value === currentImplementation) return;
try {
const result = await commands.changeKeyboardImplementationSetting(value);
if (result.status === "error") {
console.error(
"Failed to update keyboard implementation:",
result.error,
);
toast.error(String(result.error));
return;
}
// If any bindings were reset due to incompatibility, notify the user
if (result.data.reset_bindings.length > 0) {
toast.warning(t("settings.debug.keyboardImplementation.bindingsReset"));
}
await refreshSettings();
} catch (error) {
console.error("Failed to update keyboard implementation:", error);
toast.error(String(error));
}
};
return (
<SettingContainer
title={t("settings.debug.keyboardImplementation.title")}
description={t("settings.debug.keyboardImplementation.description")}
descriptionMode={descriptionMode}
grouped={grouped}
layout="horizontal"
>
<Dropdown
options={KEYBOARD_IMPLEMENTATION_OPTIONS}
selectedValue={currentImplementation}
onSelect={handleSelect}
disabled={isUpdating("keyboard_implementation")}
/>
</SettingContainer>
);
};

View file

@ -2,7 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { MicrophoneSelector } from "../MicrophoneSelector";
import { LanguageSelector } from "../LanguageSelector";
import { HandyShortcut } from "../HandyShortcut";
import { ShortcutInput } from "../ShortcutInput";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { OutputDeviceSelector } from "../OutputDeviceSelector";
import { PushToTalk } from "../PushToTalk";
@ -21,7 +21,7 @@ export const GeneralSettings: React.FC = () => {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.general.title")}>
<HandyShortcut shortcutId="transcribe" grouped={true} />
<ShortcutInput shortcutId="transcribe" grouped={true} />
{showLanguageSelector && (
<LanguageSelector descriptionMode="tooltip" grouped={true} />
)}

View file

@ -5,12 +5,10 @@ import { Button } from "../../ui/Button";
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { platform } from "@tauri-apps/plugin-os";
import { readFile } from "@tauri-apps/plugin-fs";
import { commands, type HistoryEntry } from "@/bindings";
import { formatDateTime } from "@/utils/dateFormat";
const IS_LINUX = platform() === "linux";
import { useOsType } from "@/hooks/useOsType";
interface OpenRecordingsButtonProps {
onClick: () => void;
@ -35,6 +33,7 @@ const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
export const HistorySettings: React.FC = () => {
const { t } = useTranslation();
const osType = useOsType();
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
@ -97,7 +96,7 @@ export const HistorySettings: React.FC = () => {
try {
const result = await commands.getAudioFilePath(fileName);
if (result.status === "ok") {
if (IS_LINUX) {
if (osType === "linux") {
const fileData = await readFile(result.data);
const blob = new Blob([fileData], { type: "audio/wav" });

View file

@ -14,7 +14,9 @@ export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
export { PushToTalk } from "./PushToTalk";
export { AudioFeedback } from "./AudioFeedback";
export { ShowOverlay } from "./ShowOverlay";
export { HandyShortcut } from "./HandyShortcut";
export { GlobalShortcutInput } from "./GlobalShortcutInput";
export { HandyKeysShortcutInput } from "./HandyKeysShortcutInput";
export { ShortcutInput } from "./ShortcutInput";
export { TranslateToEnglish } from "./TranslateToEnglish";
export { CustomWords } from "./CustomWords";
export { PostProcessingToggle } from "./PostProcessingToggle";

16
src/hooks/useOsType.ts Normal file
View file

@ -0,0 +1,16 @@
import { type } from "@tauri-apps/plugin-os";
import { type OSType } from "../lib/utils/keyboard";
/**
* Get the current OS type for keyboard handling.
* This is a simple wrapper - type() is synchronous.
*/
export function useOsType(): OSType {
const osType = type();
// type() returns "macos" | "windows" | "linux" | "ios" | "android"
// OSType expects "macos" | "windows" | "linux" | "unknown"
if (osType === "macos" || osType === "windows" || osType === "linux") {
return osType;
}
return "unknown";
}

View file

@ -365,6 +365,11 @@
"label": "Append Trailing Space",
"description": "Add a space after pasted transcription"
},
"keyboardImplementation": {
"title": "Keyboard Implementation",
"description": "Choose the keyboard shortcut backend.",
"bindingsReset": "Keyboard shortcuts were incompatible and reset to defaults"
},
"paths": {
"appData": "App Data:",
"models": "Models:",