diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 386e6b133..73af3ee3b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -558,12 +558,6 @@ dependencies = [ "wyz", ] -[[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" @@ -941,36 +935,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cocoa" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation 0.9.4", - "core-graphics 0.22.3", - "foreign-types 0.3.2", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -1044,19 +1008,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.3.2", - "libc", -] - [[package]] name = "core-graphics" version = "0.24.0" @@ -1065,7 +1016,7 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] @@ -1078,22 +1029,11 @@ checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - [[package]] name = "core-graphics-types" version = "0.2.0" @@ -1623,26 +1563,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "enum-map" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" -dependencies = [ - "enum-map-derive", -] - -[[package]] -name = "enum-map-derive" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.108", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -1687,16 +1607,6 @@ dependencies = [ "termcolor", ] -[[package]] -name = "epoll" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e74d68fe2927dbf47aa976d14d93db9b23dced457c7bb2bdc6925a16d31b736e" -dependencies = [ - "bitflags 2.10.0", - "libc", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -2510,6 +2420,7 @@ version = "0.6.4" dependencies = [ "anyhow", "async-openai 0.30.1", + "block2 0.6.2", "chrono", "cpal", "enigo", @@ -2520,8 +2431,10 @@ dependencies = [ "hound", "log", "natural", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", "once_cell", - "rdev", "reqwest", "rodio", "rubato", @@ -3012,26 +2925,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "instant" version = "0.1.13" @@ -3368,15 +3261,6 @@ dependencies = [ "core-foundation-sys", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -3500,18 +3384,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.1.0" @@ -3794,15 +3666,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -4978,30 +4841,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" -[[package]] -name = "rdev" -version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#a90dbe1172f8832f54c97c62e823c5a34af5fdfe" -dependencies = [ - "cocoa", - "core-foundation 0.9.4", - "core-foundation-sys", - "core-graphics 0.22.3", - "dispatch", - "enum-map", - "epoll", - "inotify", - "lazy_static", - "libc", - "log", - "mio 0.8.11", - "strum", - "strum_macros", - "widestring", - "winapi", - "x11", -] - [[package]] name = "realfft" version = "3.5.0" @@ -6237,25 +6076,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - [[package]] name = "subtle" version = "2.6.1" @@ -7241,7 +7061,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio 1.1.0", + "mio", "pin-project-lite", "socket2", "tokio-macros", @@ -8206,12 +8026,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3f4ba7f88..de1fcd8e5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,7 +45,6 @@ tauri-plugin-sql = { version = "2.3.1", features = ["sqlite"] } tauri-plugin-fs = "2.4.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -rdev = { git = "https://github.com/rustdesk-org/rdev" } cpal = "0.16.0" anyhow = "1.0.95" rubato = "0.16.2" @@ -92,6 +91,10 @@ windows = { version = "0.61.3", features = [ [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSEvent"] } +objc2-foundation = "0.3" +block2 = "0.6" [profile.release] lto = true diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index f8d490f89..bbad7c097 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -3,7 +3,6 @@ use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; use crate::settings::{get_settings, AppSettings}; -use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils::{self, show_recording_overlay, show_transcribing_overlay}; use async_openai::types::{ @@ -73,10 +72,7 @@ async fn maybe_post_process_transcription( { Some(prompt) => prompt.prompt.clone(), None => { - debug!( - "Post-processing skipped because prompt '{}' was not found", - selected_prompt_id - ); + debug!("Post-processing skipped because prompt '{selected_prompt_id}' was not found"); return None; } }; @@ -105,7 +101,7 @@ async fn maybe_post_process_transcription( let client = match crate::llm_client::create_client(&provider, api_key) { Ok(client) => client, Err(e) => { - error!("Failed to create LLM client: {}", e); + error!("Failed to create LLM client: {e}"); return None; } }; @@ -117,7 +113,7 @@ async fn maybe_post_process_transcription( { Ok(msg) => ChatCompletionRequestMessage::User(msg), Err(e) => { - error!("Failed to build chat message: {}", e); + error!("Failed to build chat message: {e}"); return None; } }; @@ -129,7 +125,7 @@ async fn maybe_post_process_transcription( { Ok(req) => req, Err(e) => { - error!("Failed to build chat completion request: {}", e); + error!("Failed to build chat completion request: {e}"); return None; } }; @@ -199,7 +195,7 @@ async fn maybe_convert_chinese_variant( Some(converted) } Err(e) => { - error!("Failed to initialize OpenCC converter: {}. Falling back to original transcription.", e); + error!("Failed to initialize OpenCC converter: {e}. Falling back to original transcription."); None } } @@ -208,7 +204,7 @@ async fn maybe_convert_chinese_variant( impl ShortcutAction for TranscribeAction { fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { let start_time = Instant::now(); - debug!("TranscribeAction::start called for binding: {}", binding_id); + debug!("TranscribeAction::start called for binding: {binding_id}"); // Load model in the background let tm = app.state::>(); @@ -223,7 +219,7 @@ impl ShortcutAction for TranscribeAction { // Get the microphone mode to determine audio feedback timing let settings = get_settings(app); let is_always_on = settings.always_on_microphone; - debug!("Microphone mode - always_on: {}", is_always_on); + debug!("Microphone mode - always_on: {is_always_on}"); let mut recording_started = false; if is_always_on { @@ -239,7 +235,7 @@ impl ShortcutAction for TranscribeAction { }); recording_started = rm.try_start_recording(&binding_id); - debug!("Recording started: {}", recording_started); + debug!("Recording started: {recording_started}"); } else { // On-demand mode: Start recording first, then play audio feedback, then apply mute // This allows the microphone to be activated before playing the sound @@ -265,8 +261,13 @@ impl ShortcutAction for TranscribeAction { } if recording_started { - // Dynamically register the cancel shortcut in a separate task to avoid deadlock - shortcut::register_cancel_shortcut(app); + // Register the cancel shortcut (Escape) so user can cancel mid-recording + let app_clone = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(e) = crate::shortcut::register_dynamic_binding(&app_clone, "cancel") { + debug!("Failed to register cancel binding: {e}"); + } + }); } debug!( @@ -276,11 +277,16 @@ impl ShortcutAction for TranscribeAction { } fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { - // Unregister the cancel shortcut when transcription stops - shortcut::unregister_cancel_shortcut(app); - let stop_time = Instant::now(); - debug!("TranscribeAction::stop called for binding: {}", binding_id); + debug!("TranscribeAction::stop called for binding: {binding_id}"); + + // Unregister cancel shortcut when transcription completes + let app_clone = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(e) = crate::shortcut::unregister_dynamic_binding(&app_clone, "cancel") { + debug!("Failed to unregister cancel binding: {e}"); + } + }); let ah = app.clone(); let rm = Arc::clone(&app.state::>()); @@ -300,10 +306,7 @@ impl ShortcutAction for TranscribeAction { tauri::async_runtime::spawn(async move { let binding_id = binding_id.clone(); // Clone for the inner async task - debug!( - "Starting async transcription task for binding: {}", - binding_id - ); + debug!("Starting async transcription task for binding: {binding_id}"); let stop_recording_time = Instant::now(); if let Some(samples) = rm.stop_recording(&binding_id) { @@ -367,7 +370,7 @@ impl ShortcutAction for TranscribeAction { ) .await { - error!("Failed to save transcription to history: {}", e); + error!("Failed to save transcription to history: {e}"); } }); @@ -380,14 +383,14 @@ impl ShortcutAction for TranscribeAction { "Text pasted successfully in {:?}", paste_time.elapsed() ), - Err(e) => error!("Failed to paste transcription: {}", e), + Err(e) => error!("Failed to paste transcription: {e}"), } // Hide the overlay after transcription is complete utils::hide_recording_overlay(&ah_clone); change_tray_icon(&ah_clone, TrayIconState::Idle); }) .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); + error!("Failed to run paste on main thread: {e:?}"); utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); }); @@ -397,7 +400,7 @@ impl ShortcutAction for TranscribeAction { } } Err(err) => { - debug!("Global Shortcut Transcription error: {}", err); + debug!("Global Shortcut Transcription error: {err}"); utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); } @@ -416,16 +419,27 @@ impl ShortcutAction for TranscribeAction { } } -// Cancel Action +// Cancel Action - cancels recording without transcribing struct CancelAction; impl ShortcutAction for CancelAction { - fn start(&self, app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { + fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { + debug!("CancelAction::start called for binding: {binding_id}"); + + // Cancel the recording (handles overlay, mute, tray icon, toggle states) utils::cancel_current_operation(app); + + // NOTE: We intentionally do NOT unregister the cancel shortcut here. + // Unregistering from inside the shortcut's own callback causes a deadlock + // because global_shortcut holds internal locks during callback execution. + // + // Instead, register_dynamic_binding is idempotent - it unregisters first + // before registering. So the next TranscribeAction::start will clean up + // any stale registration automatically. } fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str) { - // Nothing to do on stop for cancel + // Instant action - no stop behavior needed } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1981e4cf0..173c60239 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -80,6 +80,11 @@ pub struct ShortcutBinding { pub description: String, pub default_binding: String, pub current_binding: String, + /// If true, this binding is registered/unregistered dynamically at runtime + /// (e.g., cancel shortcut only active during recording). Dynamic bindings + /// are not registered at startup and not shown in the UI for editing. + #[serde(default)] + pub dynamic: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Type)] @@ -211,11 +216,13 @@ impl SoundTheme { } pub fn to_start_path(&self) -> String { - format!("resources/{}_start.wav", self.as_str()) + let name = self.as_str(); + format!("resources/{name}_start.wav") } pub fn to_stop_path(&self) -> String { - format!("resources/{}_stop.wav", self.as_str()) + let name = self.as_str(); + format!("resources/{name}_stop.wav") } } @@ -436,6 +443,7 @@ pub fn get_default_settings() -> AppSettings { description: "Converts your speech into text.".to_string(), default_binding: default_shortcut.to_string(), current_binding: default_shortcut.to_string(), + dynamic: false, }, ); bindings.insert( @@ -443,9 +451,10 @@ pub fn get_default_settings() -> AppSettings { ShortcutBinding { id: "cancel".to_string(), name: "Cancel".to_string(), - description: "Cancels the current recording.".to_string(), - default_binding: "escape".to_string(), - current_binding: "escape".to_string(), + description: "Cancel recording without transcribing.".to_string(), + default_binding: "Escape".to_string(), + current_binding: "Escape".to_string(), + dynamic: true, // Only registered while recording is active }, ); @@ -519,14 +528,14 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { // Parse the entire settings object match serde_json::from_value::(settings_value) { Ok(mut settings) => { - debug!("Found existing settings: {:?}", settings); + debug!("Found existing settings: {settings:?}"); let default_settings = get_default_settings(); let mut updated = false; // Merge default bindings into existing settings for (key, value) in default_settings.bindings { if !settings.bindings.contains_key(&key) { - debug!("Adding missing binding: {}", key); + debug!("Adding missing binding: {key}"); settings.bindings.insert(key, value); updated = true; } @@ -540,7 +549,7 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { settings } Err(e) => { - warn!("Failed to parse settings: {}", e); + warn!("Failed to parse settings: {e}"); // Fall back to default settings if parsing fails let default_settings = get_default_settings(); store.set("settings", serde_json::to_value(&default_settings).unwrap()); diff --git a/src-tauri/src/shortcut/fn_monitor.rs b/src-tauri/src/shortcut/fn_monitor.rs new file mode 100644 index 000000000..a41dfb294 --- /dev/null +++ b/src-tauri/src/shortcut/fn_monitor.rs @@ -0,0 +1,293 @@ +//! macOS fn/Globe key monitoring using NSEvent.addGlobalMonitor +//! +//! This module provides fn key support on macOS by monitoring NSEventModifierFlags::Function. +//! It requires Accessibility permission (same as enigo for pasting). +//! +//! # Architecture +//! - Uses NSEvent::addGlobalMonitorForEventsMatchingMask_handler for event monitoring +//! - Must run on the main thread +//! - Listen-only (cannot block events, which is fine for our use case) +//! +//! # Known Limitations +//! - Stops receiving events when Secure Input is enabled (password fields, 1Password, etc.) +//! - fn+key combinations conflict with system shortcuts; fn alone is safe + +use std::cell::RefCell; +use std::collections::HashMap; +use std::ptr::NonNull; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; + +use block2::RcBlock; +use log::{debug, error, info, warn}; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2_app_kit::{NSEvent, NSEventMask, NSEventModifierFlags, NSEventType}; +use objc2_foundation::{NSDictionary, NSNumber, NSString}; +use once_cell::sync::Lazy; +use tauri::AppHandle; +use tauri_plugin_global_shortcut::ShortcutState; + +use crate::settings::ShortcutBinding; + +// FFI bindings for Accessibility API +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> bool; +} + +// Key for prompting user in AXIsProcessTrustedWithOptions +const K_AX_TRUSTED_CHECK_OPTION_PROMPT: &str = "AXTrustedCheckOptionPrompt"; + +/// Check if the app has Accessibility permission. +/// If `prompt` is true, shows the system dialog to grant permission if not already granted. +pub fn check_accessibility_permission(prompt: bool) -> bool { + unsafe { + if prompt { + // Create options dictionary with prompt = true + let key = NSString::from_str(K_AX_TRUSTED_CHECK_OPTION_PROMPT); + let value = NSNumber::new_bool(true); + let keys: &[&NSString] = &[&key]; + let values: &[&NSNumber] = &[&value]; + let options = NSDictionary::from_slices(keys, values); + AXIsProcessTrustedWithOptions(Retained::as_ptr(&options) as *const std::ffi::c_void) + } else { + AXIsProcessTrustedWithOptions(std::ptr::null()) + } + } +} + +/// Check if Accessibility permission is granted (without prompting) +pub fn has_accessibility_permission() -> bool { + check_accessibility_permission(false) +} + +/// Request Accessibility permission (shows system dialog if not granted) +pub fn request_accessibility_permission() -> bool { + check_accessibility_permission(true) +} + +/// Entry for a registered fn key binding +#[derive(Clone)] +struct FnBindingEntry { + app_handle: AppHandle, + binding_id: String, + shortcut_string: String, +} + +/// State shared between the monitor callback and registration functions +#[derive(Default)] +struct FnMonitorState { + bindings: HashMap, + fn_pressed: bool, +} + +/// Handle to the monitor, stored per-thread (must be main thread) +#[derive(Default)] +struct FnMonitorHandle { + monitor_token: Option>, + #[allow(dead_code)] + handler: Option) + 'static>>, +} + +static MONITOR_STATE: Lazy>> = + Lazy::new(|| Arc::new(Mutex::new(FnMonitorState::default()))); + +static MONITOR_STARTED: AtomicBool = AtomicBool::new(false); + +thread_local! { + static MONITOR_HANDLE: RefCell = RefCell::new(FnMonitorHandle::default()); +} + +/// Register an fn key binding. +/// The binding's `current_binding` should be "fn" for this to work. +pub fn register_fn_binding(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + debug!( + "Registering fn binding: id='{}', binding='{}'", + binding.id, binding.current_binding + ); + + ensure_monitor_started(app)?; + + let mut state = MONITOR_STATE + .lock() + .map_err(|_| "Failed to lock fn monitor state".to_string())?; + + state.bindings.insert( + binding.id.clone(), + FnBindingEntry { + app_handle: app.clone(), + binding_id: binding.id, + shortcut_string: binding.current_binding, + }, + ); + + debug!( + "fn binding registered successfully. Total fn bindings: {}", + state.bindings.len() + ); + Ok(()) +} + +/// Unregister an fn key binding +pub fn unregister_fn_binding(_app: &AppHandle, binding_id: &str) -> Result<(), String> { + debug!("Unregistering fn binding: id='{binding_id}'"); + + let mut state = MONITOR_STATE + .lock() + .map_err(|_| "Failed to lock fn monitor state".to_string())?; + + if state.bindings.remove(binding_id).is_some() { + debug!( + "fn binding removed. Remaining fn bindings: {}", + state.bindings.len() + ); + if state.bindings.is_empty() { + // Reset pressed state when no bindings remain + state.fn_pressed = false; + } + } else { + debug!("fn binding '{binding_id}' was not registered"); + } + + Ok(()) +} + +/// Ensure the global fn key monitor is started on the main thread +fn ensure_monitor_started(app: &AppHandle) -> Result<(), String> { + if MONITOR_STARTED.load(Ordering::SeqCst) { + debug!("fn monitor already started"); + return Ok(()); + } + + debug!("Starting fn key monitor..."); + + // Check Accessibility permission first (shows system dialog if not granted) + if !has_accessibility_permission() { + info!("Accessibility permission not granted, prompting user..."); + let granted = request_accessibility_permission(); + if !granted { + return Err( + "Accessibility permission is required for fn key shortcuts. \ + Please grant permission in System Settings > Privacy & Security > Accessibility, \ + then restart Handy." + .to_string(), + ); + } + info!("Accessibility permission granted"); + } + + let state = Arc::clone(&MONITOR_STATE); + let (tx, rx) = mpsc::channel(); + + let schedule_result = app.run_on_main_thread(move || { + MONITOR_HANDLE.with(|handle_cell| { + let mut handle = handle_cell.borrow_mut(); + if handle.monitor_token.is_some() { + MONITOR_STARTED.store(true, Ordering::SeqCst); + let _ = tx.send(Ok(())); + return; + } + + let state_for_handler = Arc::clone(&state); + let handler = RcBlock::new(move |event: NonNull| { + // SAFETY: The event pointer is valid for the duration of the callback + let event_ref = unsafe { event.as_ref() }; + + // Only process modifier flag changes + let event_type = event_ref.r#type(); + if event_type != NSEventType::FlagsChanged { + return; + } + + let flags = event_ref.modifierFlags(); + process_modifier_flags(&state_for_handler, flags); + }); + + // Install the global monitor + let monitor = NSEvent::addGlobalMonitorForEventsMatchingMask_handler( + NSEventMask::FlagsChanged, + &handler, + ); + + match monitor { + Some(token) => { + debug!("fn key monitor installed successfully"); + handle.monitor_token = Some(token); + handle.handler = Some(handler); + MONITOR_STARTED.store(true, Ordering::SeqCst); + let _ = tx.send(Ok(())); + } + None => { + error!("Failed to install fn key monitor - Accessibility permission may be missing"); + handle.monitor_token = None; + handle.handler = None; + MONITOR_STARTED.store(false, Ordering::SeqCst); + let _ = tx.send(Err( + "Failed to install fn key monitor. Please grant Handy Accessibility permission in System Settings > Privacy & Security > Accessibility.".to_string() + )); + } + } + }); + }); + + if let Err(err) = schedule_result { + return Err(format!( + "Failed to schedule fn monitor on main thread: {err}" + )); + } + + rx.recv() + .unwrap_or_else(|_| Err("fn monitor setup did not complete".to_string())) +} + +/// Process modifier flag changes and dispatch events for fn key +fn process_modifier_flags(state: &Arc>, flags: NSEventModifierFlags) { + let is_pressed = flags.contains(NSEventModifierFlags::Function); + + let bindings: Vec = { + let mut guard = match state.lock() { + Ok(guard) => guard, + Err(_) => { + warn!("fn monitor state lock poisoned"); + return; + } + }; + + // Skip if state hasn't changed + if guard.fn_pressed == is_pressed { + return; + } + + guard.fn_pressed = is_pressed; + + if guard.bindings.is_empty() { + return; + } + + guard.bindings.values().cloned().collect() + }; + + let shortcut_state = if is_pressed { + ShortcutState::Pressed + } else { + ShortcutState::Released + }; + + debug!( + "fn key {}, dispatching to {} binding(s)", + if is_pressed { "pressed" } else { "released" }, + bindings.len() + ); + + // Dispatch to all registered fn bindings + for binding in bindings { + super::dispatch_binding_event( + &binding.app_handle, + &binding.binding_id, + &binding.shortcut_string, + shortcut_state, + ); + } +} diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut/mod.rs similarity index 68% rename from src-tauri/src/shortcut.rs rename to src-tauri/src/shortcut/mod.rs index fdd5d758b..ab683cec9 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -1,36 +1,70 @@ -use log::{error, warn}; +use log::{debug, error, 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, }; use crate::ManagedToggleState; +#[cfg(target_os = "macos")] +mod fn_monitor; + +/// Check if a binding string represents an fn-key-only binding (macOS) +fn is_fn_binding(binding: &str) -> bool { + binding.eq_ignore_ascii_case("fn") +} + +/// Register a binding, routing to the appropriate handler based on binding type +fn register_binding(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + debug!( + "register_binding: id='{}', current_binding='{}'", + binding.id, binding.current_binding + ); + + #[cfg(target_os = "macos")] + if is_fn_binding(&binding.current_binding) { + return fn_monitor::register_fn_binding(app, binding); + } + + _register_shortcut(app, binding) +} + +/// Unregister a binding, routing to the appropriate handler based on binding type +fn unregister_binding(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + #[cfg(target_os = "macos")] + if is_fn_binding(&binding.current_binding) { + return fn_monitor::unregister_fn_binding(app, &binding.id); + } + + unregister_shortcut(app, binding) +} + 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 + // Skip dynamic bindings - they are registered at runtime when needed 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); + if binding.dynamic { + debug!("Skipping dynamic binding '{id}' during init"); + continue; + } + + if let Err(e) = register_binding(app, binding) { + error!("Failed to register shortcut {id} during init: {e}"); } } } @@ -55,8 +89,8 @@ pub fn change_binding( let binding_to_modify = match settings.bindings.get(&id) { Some(binding) => binding.clone(), None => { - let error_msg = format!("Binding with id '{}' not found", id); - warn!("change_binding error: {}", error_msg); + let error_msg = format!("Binding with id '{id}' not found"); + warn!("change_binding error: {error_msg}"); return Ok(BindingResponse { success: false, binding: None, @@ -79,15 +113,14 @@ pub fn change_binding( } } - // Unregister the existing binding - if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) { - let error_msg = format!("Failed to unregister shortcut: {}", e); - error!("change_binding error: {}", error_msg); + // Unregister the existing binding (ignore errors - it may not be registered) + if let Err(e) = unregister_binding(&app, binding_to_modify.clone()) { + debug!("change_binding: could not unregister existing binding: {e}"); } // Validate the new shortcut before we touch the current registration if let Err(e) = validate_shortcut_string(&binding) { - warn!("change_binding validation error: {}", e); + warn!("change_binding validation error: {e}"); return Err(e); } @@ -96,9 +129,9 @@ pub fn change_binding( updated_binding.current_binding = binding; // Register the new binding - if let Err(e) = register_shortcut(&app, updated_binding.clone()) { - let error_msg = format!("Failed to register shortcut: {}", e); - error!("change_binding error: {}", error_msg); + if let Err(e) = register_binding(&app, updated_binding.clone()) { + let error_msg = format!("Failed to register shortcut: {e}"); + error!("change_binding error: {error_msg}"); return Ok(BindingResponse { success: false, binding: None, @@ -125,7 +158,7 @@ pub fn change_binding( pub fn reset_binding(app: AppHandle, id: String) -> Result { let binding = settings::get_stored_binding(&app, &id); - return change_binding(app, id, binding.default_binding); + change_binding(app, id, binding.default_binding) } #[tauri::command] @@ -169,7 +202,7 @@ pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), S "pop" => SoundTheme::Pop, "custom" => SoundTheme::Custom, other => { - warn!("Invalid sound theme '{}', defaulting to marimba", other); + warn!("Invalid sound theme '{other}', defaulting to marimba"); SoundTheme::Marimba } }; @@ -205,7 +238,7 @@ pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Resu "top" => OverlayPosition::Top, "bottom" => OverlayPosition::Bottom, other => { - warn!("Invalid overlay position '{}', defaulting to bottom", other); + warn!("Invalid overlay position '{other}', defaulting to bottom"); OverlayPosition::Bottom } }; @@ -332,7 +365,7 @@ pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), "none" => PasteMethod::None, "shift_insert" => PasteMethod::ShiftInsert, other => { - warn!("Invalid paste method '{}', defaulting to ctrl_v", other); + warn!("Invalid paste method '{other}', defaulting to ctrl_v"); PasteMethod::CtrlV } }; @@ -349,10 +382,7 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re "dont_modify" => ClipboardHandling::DontModify, "copy_to_clipboard" => ClipboardHandling::CopyToClipboard, other => { - warn!( - "Invalid clipboard handling '{}', defaulting to dont_modify", - other - ); + warn!("Invalid clipboard handling '{other}', defaulting to dont_modify"); ClipboardHandling::DontModify } }; @@ -381,7 +411,7 @@ pub fn change_post_process_base_url_setting( let label = settings .post_process_provider(&provider_id) .map(|provider| provider.label.clone()) - .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; + .ok_or_else(|| format!("Provider '{provider_id}' not found"))?; let provider = settings .post_process_provider_mut(&provider_id) @@ -389,8 +419,7 @@ pub fn change_post_process_base_url_setting( if !provider.allow_base_url_edit { return Err(format!( - "Provider '{}' does not allow editing the base URL", - label + "Provider '{label}' does not allow editing the base URL" )); } @@ -409,7 +438,7 @@ fn validate_provider_exists( .iter() .any(|provider| provider.id == provider_id) { - return Err(format!("Provider '{}' not found", provider_id)); + return Err(format!("Provider '{provider_id}' not found")); } Ok(()) } @@ -496,7 +525,7 @@ pub fn update_post_process_prompt( settings::write_settings(&app, settings); Ok(()) } else { - Err(format!("Prompt with id '{}' not found", id)) + Err(format!("Prompt with id '{id}' not found")) } } @@ -515,7 +544,7 @@ pub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), Stri settings.post_process_prompts.retain(|p| p.id != id); if settings.post_process_prompts.len() == original_len { - return Err(format!("Prompt with id '{}' not found", id)); + return Err(format!("Prompt with id '{id}' not found")); } // If the deleted prompt was selected, select the first one or None @@ -541,7 +570,7 @@ pub async fn fetch_post_process_models( .post_process_providers .iter() .find(|p| p.id == provider_id) - .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; + .ok_or_else(|| format!("Provider '{provider_id}' not found"))?; // Get API key let api_key = settings @@ -580,7 +609,7 @@ async fn fetch_models_manual( .as_ref() .map(|s| s.trim_start_matches('/')) .unwrap_or("models"); - let endpoint = format!("{}/{}", base_url, models_endpoint); + let endpoint = format!("{base_url}/{models_endpoint}"); // Create HTTP client with headers let mut headers = reqwest::header::HeaderMap::new(); @@ -599,7 +628,7 @@ async fn fetch_models_manual( headers.insert( "x-api-key", reqwest::header::HeaderValue::from_str(&api_key) - .map_err(|e| format!("Invalid API key: {}", e))?, + .map_err(|e| format!("Invalid API key: {e}"))?, ); } headers.insert( @@ -609,22 +638,22 @@ async fn fetch_models_manual( } else if !api_key.is_empty() { headers.insert( "Authorization", - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key)) - .map_err(|e| format!("Invalid API key: {}", e))?, + reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}")) + .map_err(|e| format!("Invalid API key: {e}"))?, ); } let http_client = reqwest::Client::builder() .default_headers(headers) .build() - .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; // Make the request let response = http_client .get(&endpoint) .send() .await - .map_err(|e| format!("Failed to fetch models: {}", e))?; + .map_err(|e| format!("Failed to fetch models: {e}"))?; if !response.status().is_success() { let status = response.status(); @@ -633,8 +662,7 @@ async fn fetch_models_manual( .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(format!( - "Model list request failed ({}): {}", - status, error_text + "Model list request failed ({status}): {error_text}" )); } @@ -642,7 +670,7 @@ async fn fetch_models_manual( let parsed: serde_json::Value = response .json() .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + .map_err(|e| format!("Failed to parse response: {e}"))?; let mut models = Vec::new(); @@ -675,7 +703,7 @@ pub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<() // Verify the prompt exists if !settings.post_process_prompts.iter().any(|p| p.id == id) { - return Err(format!("Prompt with id '{}' not found", id)); + return Err(format!("Prompt with id '{id}' not found")); } settings.post_process_selected_prompt_id = Some(id); @@ -696,7 +724,16 @@ pub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Res /// Determine whether a shortcut string contains at least one non-modifier key. /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow /// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). +/// Special case: "fn" is allowed as a macOS-specific modifier-only binding. fn validate_shortcut_string(raw: &str) -> Result<(), String> { + // "fn" is only valid on macOS + if is_fn_binding(raw) { + #[cfg(target_os = "macos")] + return Ok(()); + #[cfg(not(target_os = "macos"))] + return Err("The fn key shortcut is only supported on macOS".into()); + } + let modifiers = [ "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", "windows", @@ -717,8 +754,8 @@ fn validate_shortcut_string(raw: &str) -> Result<(), String> { #[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); + if let Err(e) = unregister_binding(&app, b) { + error!("suspend_binding error for id '{id}': {e}"); return Err(e); } } @@ -730,166 +767,198 @@ pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> { #[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); + if let Err(e) = register_binding(&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 +/// Register a dynamic binding at runtime. +/// Dynamic bindings are shortcuts that are only active during certain states +/// (e.g., cancel shortcut only active while recording). +/// +/// This function is idempotent - it will first unregister any existing binding +/// before registering. This allows safe re-registration without needing to +/// explicitly unregister first (which can deadlock if called from inside a +/// shortcut callback). +/// +/// Note: Dynamic shortcut registration is disabled on Linux due to instability +/// with the global shortcut plugin. See PR #392. +pub fn register_dynamic_binding(app: &AppHandle, binding_id: &str) -> Result<(), String> { + // Dynamic shortcut registration is disabled on Linux due to instability #[cfg(target_os = "linux")] { - let _ = app; - return; + let _ = (app, binding_id); + debug!( + "Skipping dynamic binding registration on Linux (disabled for stability): {binding_id}" + ); + return Ok(()); } #[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); - } - } - }); + let settings = get_settings(app); + + let binding = settings + .bindings + .get(binding_id) + .ok_or_else(|| format!("Dynamic binding '{binding_id}' not found in settings"))?; + + debug!( + "register_dynamic_binding: id='{}', binding='{}'", + binding.id, binding.current_binding + ); + + if !binding.dynamic { + return Err(format!("Binding '{binding_id}' is not marked as dynamic")); + } + + // Try to unregister first (ignore errors - might not be registered) + // This makes registration idempotent and avoids "already in use" errors + let _ = unregister_binding(app, binding.clone()); + + register_binding(app, binding.clone()) } } -pub fn unregister_cancel_shortcut(app: &AppHandle) { - // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration +/// Unregister a dynamic binding at runtime. +/// +/// Note: Dynamic shortcut registration is disabled on Linux due to instability. +/// See PR #392. +pub fn unregister_dynamic_binding(app: &AppHandle, binding_id: &str) -> Result<(), String> { + // Dynamic shortcut registration is disabled on Linux due to instability #[cfg(target_os = "linux")] { - let _ = app; - return; + let _ = (app, binding_id); + return Ok(()); } #[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); - } - }); + let settings = get_settings(app); + + let binding = settings + .bindings + .get(binding_id) + .ok_or_else(|| format!("Dynamic binding '{binding_id}' not found in settings"))?; + + debug!("Unregistering dynamic binding: {binding_id}"); + unregister_binding(app, binding.clone()) } } -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) { +/// Dispatch a binding event to the appropriate action. +/// Shared by both regular shortcuts and fn key monitor. +pub(crate) fn dispatch_binding_event( + app: &AppHandle, + binding_id: &str, + shortcut_string: &str, + state: ShortcutState, +) { + debug!( + "dispatch_binding_event: binding_id='{binding_id}', shortcut='{shortcut_string}', state={state:?}" + ); + let settings = get_settings(app); + + if let Some(action) = ACTION_MAP.get(binding_id) { + if settings.push_to_talk { + // Push-to-talk mode: start on press, stop on release + if state == ShortcutState::Pressed { + action.start(app, binding_id, shortcut_string); + } else if state == ShortcutState::Released { + action.stop(app, binding_id, shortcut_string); + } + } else { + // Toggle mode: toggle on press only + if 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 = app.state::(); + 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, shortcut_string); + } else { + action.stop(app, binding_id, shortcut_string); + } + } + } + } else { warn!( - "_register_shortcut validation error for binding '{}': {}", - binding.current_binding, e + "No action defined in ACTION_MAP for binding ID '{binding_id}'. Shortcut: '{shortcut_string}', State: {state:?}" ); - return Err(e); } +} + +fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + // Validate human-level rules first + validate_shortcut_string(&binding.current_binding)?; // Parse shortcut and return error if it fails let shortcut = match binding.current_binding.parse::() { 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); + let binding_str = &binding.current_binding; + return Err(format!("Failed to parse shortcut '{binding_str}': {e}")); } }; // 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); + let binding_str = &binding.current_binding; + return Err(format!("Shortcut '{binding_str}' is already in use")); } - // Clone binding.id for use in the closure - let binding_id_for_closure = binding.id.clone(); + // Clone binding info for use in the closure + let binding_id = binding.id.clone(); + let shortcut_string = binding.current_binding.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::>(); - 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 { - if event.state == ShortcutState::Pressed { - let toggle_state_manager = ah.state::(); - - 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); - - if *is_currently_active { - action.stop( - ah, - &binding_id_for_closure, - &shortcut_string, - ); - *is_currently_active = false; // Update state to inactive - } else { - action.start(ah, &binding_id_for_closure, &shortcut_string); - *is_currently_active = true; // Update state to active - } - } - } - } else { - warn!( - "No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}", - binding_id_for_closure, shortcut_string, event.state - ); - } + dispatch_binding_event(ah, &binding_id, &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 + let binding_str = &binding.current_binding; + format!("Couldn't register shortcut '{binding_str}': {e}") })?; Ok(()) } pub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + let binding_str = &binding.current_binding; let shortcut = match binding.current_binding.parse::() { 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); + let error_msg = + format!("Failed to parse shortcut '{binding_str}' for unregistration: {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); + let error_msg = format!("Failed to unregister shortcut '{binding_str}': {e}"); + error!("_unregister_shortcut error: {error_msg}"); error_msg })?; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 636ef8ef2..572df9ecb 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,5 +1,4 @@ use crate::managers::audio::AudioRecordingManager; -use crate::shortcut; use crate::ManagedToggleState; use log::{info, warn}; use std::sync::Arc; @@ -12,15 +11,26 @@ pub use crate::overlay::*; pub use crate::tray::*; /// Centralized cancellation function that can be called from anywhere in the app. -/// Handles cancelling both recording and transcription operations and updates UI state. +/// Handles cancelling recording operations and updates UI state. +/// +/// IMPORTANT: This function discards the recording without transcribing. +/// It does NOT call action.stop() because that would trigger transcription. pub fn cancel_current_operation(app: &AppHandle) { - info!("Initiating operation cancellation..."); + info!("Cancelling current operation"); - // Unregister the cancel shortcut asynchronously - shortcut::unregister_cancel_shortcut(app); + // FIRST: Cancel any ongoing recording (BEFORE touching toggle states!) + // This ensures audio is discarded and state is set to Idle. + // Must happen first so that if TranscribeAction.stop() is called later + // (e.g., user releases PTT key), stop_recording() returns None. + let audio_manager = app.state::>(); + audio_manager.cancel_recording(); - // First, reset all shortcut toggle states. - // This is critical for non-push-to-talk mode where shortcuts toggle on/off + // Remove any applied mute (in case mute-while-recording was enabled) + audio_manager.remove_mute(); + + // Reset all shortcut toggle states WITHOUT calling action.stop() + // We intentionally don't call action.stop() because that would trigger + // transcription - we want to discard, not complete. let toggle_state_manager = app.state::(); if let Ok(mut states) = toggle_state_manager.lock() { states.active_toggles.values_mut().for_each(|v| *v = false); @@ -28,14 +38,12 @@ pub fn cancel_current_operation(app: &AppHandle) { warn!("Failed to lock toggle state manager during cancellation"); } - // Cancel any ongoing recording - let audio_manager = app.state::>(); - audio_manager.cancel_recording(); - - // Update tray icon and hide overlay - change_tray_icon(app, crate::tray::TrayIconState::Idle); + // Hide the recording/transcribing overlay hide_recording_overlay(app); + // Update tray icon to idle state + change_tray_icon(app, TrayIconState::Idle); + info!("Operation cancellation completed - returned to idle state"); } diff --git a/src/bindings.ts b/src/bindings.ts index ebde2990a..39d3d4651 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -618,7 +618,13 @@ export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" -export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } +export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string; +/** + * If true, this binding is registered/unregistered dynamically at runtime + * (e.g., cancel shortcut only active during recording). Dynamic bindings + * are not registered at startup and not shown in the UI for editing. + */ +dynamic?: boolean } export type SoundTheme = "marimba" | "pop" | "custom" /** tauri-specta globals **/ diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 797f36bca..f1ea85f16 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -302,6 +302,7 @@ export const HandyShortcut: React.FC = ({
startRecording(shortcutId)} + title="Click to record new shortcut" > {formatKeyCombination(binding.current_binding, osType)}