diff --git a/bun.lockb b/bun.lockb index fe07be3..8fbc20a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 381b230..ae35e40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "handy-app", "private": true, - "version": "0.3.3", + "version": "0.3.5", "type": "module", "scripts": { "dev": "vite", @@ -25,6 +25,7 @@ "@tauri-apps/plugin-upload": "~2", "react": "^18.3.1", "react-dom": "^18.3.1", + "sonner": "^2.0.7", "tailwindcss": "^4.0.2", "tauri-plugin-macos-permissions-api": "^2.0.4", "zod": "^3.24.4" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c129381..fa97f50 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -204,17 +204,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - [[package]] name = "async-io" version = "2.4.0" @@ -2003,7 +1992,7 @@ dependencies = [ [[package]] name = "handy" -version = "0.3.3" +version = "0.3.5" dependencies = [ "anyhow", "cpal", @@ -2707,9 +2696,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" [[package]] name = "libloading" @@ -3026,9 +3015,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -5553,16 +5542,16 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.2.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c387d4d96690131dc46d1d2827df5c222b896a2bfeb15a16267229a55c50b5" +checksum = "50a0e5a4ce43cb3a733c3aef85e8478bc769dac743c615e26639cbf5d953faf7" dependencies = [ "serde", "serde_json", "tauri", "thiserror 2.0.11", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "zbus", ] @@ -6840,6 +6829,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -6888,10 +6886,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6917,7 +6916,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c12476c23a74725c539b24eae8bfc0dac4029c39cdb561d9f23616accd4ae26d" dependencies = [ - "windows-targets 0.53.0", + "windows-targets 0.53.3", ] [[package]] @@ -7109,15 +7108,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.6.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.0" @@ -7270,16 +7260,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "xkbcommon" version = "0.8.0" @@ -7323,13 +7303,12 @@ dependencies = [ [[package]] name = "zbus" -version = "5.3.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2494e4b3f44d8363eef79a8a75fc0649efb710eef65a66b5e688a5eb4afe678a" +checksum = "4bb4f9a464286d42851d18a605f7193b8febaf5b0919d71c6399b7b26e5b0aad" dependencies = [ "async-broadcast", "async-executor", - "async-fs", "async-io", "async-lock", "async-process", @@ -7340,18 +7319,16 @@ dependencies = [ "enumflags2", "event-listener", "futures-core", - "futures-util", + "futures-lite", "hex", "nix", "ordered-stream", "serde", "serde_repr", - "static_assertions", "tracing", "uds_windows", "windows-sys 0.59.0", - "winnow 0.6.26", - "xdg-home", + "winnow 0.7.0", "zbus_macros", "zbus_names", "zvariant", @@ -7359,9 +7336,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.3.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445efc01929302aee95e2b25bbb62a301ea8a6369466e4278e58e7d1dfb23631" +checksum = "ef9859f68ee0c4ee2e8cde84737c78e3f4c54f946f2a38645d0d4c7a95327659" dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", @@ -7374,13 +7351,13 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.1.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519629a3f80976d89c575895b05677cbc45eaf9f70d62a364d819ba646409cc8" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow 0.6.26", + "winnow 0.7.0", "zvariant", ] @@ -7471,24 +7448,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.2.0" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55e6b9b5f1361de2d5e7d9fd1ee5f6f7fcb6060618a1f82f3472f58f2b8d4be9" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" dependencies = [ "endi", "enumflags2", "serde", - "static_assertions", - "winnow 0.6.26", + "winnow 0.7.0", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.2.0" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "573a8dd76961957108b10f7a45bac6ab1ea3e9b7fe01aff88325dc57bb8f5c8b" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", @@ -7499,14 +7475,14 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd46446ea2a1f353bfda53e35f17633afa79f4fe290a611c94645c69fe96a50" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" dependencies = [ "proc-macro2", "quote", "serde", "static_assertions", "syn 2.0.96", - "winnow 0.6.26", + "winnow 0.7.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bb65c43..c9d3041 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "handy" -version = "0.3.3" +version = "0.3.5" description = "Handy" authors = ["cjpais"] edition = "2021" @@ -68,10 +68,10 @@ whisper-rs = { version = "0.13.2", features = ["metal"] } whisper-rs = { version = "0.13.2", features = ["vulkan"] } [target.'cfg(target_os = "linux")'.dependencies] -whisper-rs = { version = "0.13.2", features = ["openblas", "vulkan"] } +whisper-rs = { version = "0.13.2", features = ["vulkan"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" tauri-plugin-global-shortcut = "2" -tauri-plugin-single-instance = "2" +tauri-plugin-single-instance = "2.3.2" tauri-plugin-updater = "2" diff --git a/src-tauri/resources/tray_idle_dark.png b/src-tauri/resources/tray_idle_dark.png new file mode 100644 index 0000000..873b11b Binary files /dev/null and b/src-tauri/resources/tray_idle_dark.png differ diff --git a/src-tauri/resources/tray_recording_dark.png b/src-tauri/resources/tray_recording_dark.png new file mode 100644 index 0000000..d37b8bd Binary files /dev/null and b/src-tauri/resources/tray_recording_dark.png differ diff --git a/src-tauri/resources/tray_transcribing_dark.png b/src-tauri/resources/tray_transcribing_dark.png new file mode 100644 index 0000000..d3d9cd2 Binary files /dev/null and b/src-tauri/resources/tray_transcribing_dark.png differ diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index 61eff63..5f617fc 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -108,6 +108,16 @@ pub async fn has_any_models_or_downloads( Ok(models.iter().any(|m| m.is_downloaded)) } +#[tauri::command] +pub async fn cancel_download( + model_manager: State<'_, Arc>, + model_id: String, +) -> Result<(), String> { + model_manager + .cancel_download(&model_id) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_recommended_first_model() -> Result { // Recommend small model for first-time users diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b5c5a7..68b5b52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,9 @@ pub fn run() { env_logger::init(); tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + show_main_window(app); + })) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) @@ -75,9 +78,25 @@ pub fn run() { )) .manage(Mutex::new(ShortcutToggleStates::default())) .setup(move |app| { + // Get the current theme to set the appropriate initial icon + let initial_theme = if let Some(main_window) = app.get_webview_window("main") { + main_window.theme().unwrap_or(tauri::Theme::Dark) + } else { + tauri::Theme::Dark + }; + + println!("Initial system theme: {:?}", initial_theme); + + // Choose the appropriate initial icon based on theme + let initial_icon_path = match initial_theme { + tauri::Theme::Dark => "resources/tray_idle.png", + tauri::Theme::Light => "resources/tray_idle_dark.png", + _ => "resources/tray_idle.png", // Default fallback + }; + let tray = TrayIconBuilder::new() .icon(Image::from_path(app.path().resolve( - "resources/tray_idle.png", + initial_icon_path, tauri::path::BaseDirectory::Resource, )?)?) .show_menu_on_left_click(true) @@ -148,6 +167,11 @@ pub fn run() { } } } + tauri::WindowEvent::ThemeChanged(theme) => { + println!("Theme changed to: {:?}", theme); + // Update tray icon to match new theme, maintaining idle state + utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle); + } _ => {} }) .invoke_handler(tauri::generate_handler![ @@ -159,6 +183,8 @@ pub fn run() { shortcut::change_selected_language_setting, shortcut::change_show_overlay_setting, shortcut::change_debug_mode_setting, + shortcut::suspend_binding, + shortcut::resume_binding, trigger_update_check, commands::cancel_operation, commands::get_app_dir_path, @@ -166,6 +192,7 @@ pub fn run() { commands::models::get_model_info, commands::models::download_model, commands::models::delete_model, + commands::models::cancel_download, commands::models::set_active_model, commands::models::get_current_model, commands::models::get_transcription_model_status, diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 943e77c..96a988c 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -18,6 +18,8 @@ pub struct ModelInfo { pub url: Option, pub size_mb: u64, pub is_downloaded: bool, + pub is_downloading: bool, + pub partial_size: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,6 +63,8 @@ impl ModelManager { url: Some("https://blob.handy.computer/ggml-small.bin".to_string()), size_mb: 244, is_downloaded: false, + is_downloading: false, + partial_size: 0, }, ); @@ -75,6 +79,8 @@ impl ModelManager { url: Some("https://blob.handy.computer/whisper-medium-q4_1.bin".to_string()), size_mb: 491, // Approximate size is_downloaded: false, + is_downloading: false, + partial_size: 0, }, ); @@ -88,6 +94,8 @@ impl ModelManager { url: Some("https://blob.handy.computer/ggml-large-v3-turbo.bin".to_string()), size_mb: 1600, // Approximate size is_downloaded: false, + is_downloading: false, + partial_size: 0, }, ); @@ -101,6 +109,8 @@ impl ModelManager { url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()), size_mb: 1080, // Approximate size is_downloaded: false, + is_downloading: false, + partial_size: 0, }, ); @@ -164,7 +174,17 @@ impl ModelManager { for model in models.values_mut() { let model_path = self.models_dir.join(&model.filename); + let partial_path = self.models_dir.join(format!("{}.partial", &model.filename)); + model.is_downloaded = model_path.exists(); + model.is_downloading = partial_path.exists(); + + // Get partial file size if it exists + if partial_path.exists() { + model.partial_size = partial_path.metadata().map(|m| m.len()).unwrap_or(0); + } else { + model.partial_size = 0; + } } Ok(()) @@ -209,37 +229,113 @@ impl ModelManager { .url .ok_or_else(|| anyhow::anyhow!("No download URL for model"))?; let model_path = self.models_dir.join(&model_info.filename); + let partial_path = self + .models_dir + .join(format!("{}.partial", &model_info.filename)); - // Don't download if downloaded version already exists + // Don't download if complete version already exists if model_path.exists() { - // Update status and return + // Clean up any partial file that might exist + if partial_path.exists() { + let _ = fs::remove_file(&partial_path); + } self.update_download_status()?; return Ok(()); } - println!("Downloading model {} from {}", model_id, url); + // Check if we have a partial download to resume + let resume_from = if partial_path.exists() { + let size = partial_path.metadata()?.len(); + println!("Resuming download of model {} from byte {}", model_id, size); + size + } else { + println!("Starting fresh download of model {} from {}", model_id, url); + 0 + }; - // Create HTTP client + // Mark as downloading + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = true; + } + } + + // Create HTTP client with range request for resuming let client = reqwest::Client::new(); - let response = client.get(&url).send().await?; + let mut request = client.get(&url); - if !response.status().is_success() { + if resume_from > 0 { + request = request.header("Range", format!("bytes={}-", resume_from)); + } + + let response = request.send().await?; + + // Check for success or partial content status + if !response.status().is_success() + && response.status() != reqwest::StatusCode::PARTIAL_CONTENT + { + // Mark as not downloading on error + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + } + } return Err(anyhow::anyhow!( "Failed to download model: HTTP {}", response.status() )); } - let total_size = response.content_length().unwrap_or(0); - let mut downloaded = 0u64; + let total_size = if resume_from > 0 { + // For resumed downloads, add the resume point to content length + resume_from + response.content_length().unwrap_or(0) + } else { + response.content_length().unwrap_or(0) + }; + + let mut downloaded = resume_from; let mut stream = response.bytes_stream(); - // Create the file - let mut file = std::fs::File::create(&model_path)?; + // Open file for appending if resuming, or create new if starting fresh + let mut file = if resume_from > 0 { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&partial_path)? + } else { + std::fs::File::create(&partial_path)? + }; + + // Emit initial progress + let initial_progress = DownloadProgress { + model_id: model_id.to_string(), + downloaded, + total: total_size, + percentage: if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }, + }; + let _ = self + .app_handle + .emit("model-download-progress", &initial_progress); // Download with progress while let Some(chunk) = stream.next().await { - let chunk = chunk?; + let chunk = chunk.map_err(|e| { + // Mark as not downloading on error + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + } + } + e + })?; + file.write_all(&chunk)?; downloaded += chunk.len() as u64; @@ -261,9 +357,20 @@ impl ModelManager { } file.flush()?; + drop(file); // Ensure file is closed before moving + + // Move partial file to final location + fs::rename(&partial_path, &model_path)?; // Update download status - self.update_download_status()?; + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + model.is_downloaded = true; + model.partial_size = 0; + } + } // Emit completion event let _ = self.app_handle.emit("model-download-complete", model_id); @@ -272,6 +379,7 @@ impl ModelManager { "Successfully downloaded model {} to {:?}", model_id, model_path ); + Ok(()) } @@ -289,14 +397,32 @@ impl ModelManager { println!("ModelManager: Found model info: {:?}", model_info); let model_path = self.models_dir.join(&model_info.filename); + let partial_path = self + .models_dir + .join(format!("{}.partial", &model_info.filename)); println!("ModelManager: Model path: {:?}", model_path); + println!("ModelManager: Partial path: {:?}", partial_path); + let mut deleted_something = false; + + // Delete complete model file if it exists if model_path.exists() { println!("ModelManager: Deleting model file at: {:?}", model_path); fs::remove_file(&model_path)?; println!("ModelManager: Model file deleted successfully"); - } else { - return Err(anyhow::anyhow!("Model file not found")); + deleted_something = true; + } + + // Delete partial file if it exists + if partial_path.exists() { + println!("ModelManager: Deleting partial file at: {:?}", partial_path); + fs::remove_file(&partial_path)?; + println!("ModelManager: Partial file deleted successfully"); + deleted_something = true; + } + + if !deleted_something { + return Err(anyhow::anyhow!("No model files found to delete")); } // Update download status @@ -315,11 +441,57 @@ impl ModelManager { return Err(anyhow::anyhow!("Model not available: {}", model_id)); } + // Ensure we don't return partial files + if model_info.is_downloading { + return Err(anyhow::anyhow!( + "Model is currently downloading: {}", + model_id + )); + } + let model_path = self.models_dir.join(&model_info.filename); - if model_path.exists() { + let partial_path = self + .models_dir + .join(format!("{}.partial", &model_info.filename)); + + // Ensure we only return complete model files, not partial ones + if model_path.exists() && !partial_path.exists() { Ok(model_path) } else { - Err(anyhow::anyhow!("Model file not found: {}", model_id)) + Err(anyhow::anyhow!( + "Complete model file not found: {}", + model_id + )) } } + + pub fn cancel_download(&self, model_id: &str) -> Result<()> { + println!("ModelManager: cancel_download called for: {}", model_id); + + let _model_info = { + let models = self.available_models.lock().unwrap(); + models.get(model_id).cloned() + }; + + let _model_info = + _model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?; + + // Mark as not downloading + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(model_id) { + model.is_downloading = false; + } + } + + // Note: The actual download cancellation would need to be handled + // by the download task itself. This just updates the state. + // The partial file is kept so the download can be resumed later. + + // Update download status to reflect current state + self.update_download_status()?; + + println!("ModelManager: Download cancelled for: {}", model_id); + Ok(()) + } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index d2d609b..7dc7da2 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -15,7 +15,7 @@ pub fn init_shortcuts(app: &App) { for (_id, binding) in settings.bindings { // Pass app.handle() which is &AppHandle if let Err(e) = _register_shortcut(app.handle(), binding) { - eprintln!("Failed to register shortcut {}: {}", _id, e); + eprintln!("Failed to register shortcut {} during init: {}", _id, e); } } } @@ -39,21 +39,26 @@ 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); + eprintln!("change_binding error: {}", error_msg); return Ok(BindingResponse { success: false, binding: None, - error: Some(format!("Binding with id '{}' not found", id)), - }) + error: Some(error_msg), + }); } }; // Unregister the existing binding if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) { - return Ok(BindingResponse { - success: false, - binding: None, - error: Some(format!("Failed to unregister shortcut: {}", e)), - }); + let error_msg = format!("Failed to unregister shortcut: {}", e); + eprintln!("change_binding error: {}", error_msg); + } + + // Validate the new shortcut before we touch the current registration + if let Err(e) = validate_shortcut_string(&binding) { + eprintln!("change_binding validation error: {}", e); + return Err(e); } // Create an updated binding @@ -62,10 +67,12 @@ pub fn change_binding( // Register the new binding if let Err(e) = _register_shortcut(&app, updated_binding.clone()) { + let error_msg = format!("Failed to register shortcut: {}", e); + eprintln!("change_binding error: {}", error_msg); return Ok(BindingResponse { success: false, binding: None, - error: Some(format!("Failed to register shortcut: {}", e)), + error: Some(error_msg), }); } @@ -143,13 +150,79 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St Ok(()) } +/// 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"). +fn validate_shortcut_string(raw: &str) -> Result<(), String> { + 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 contain at least one non-modifier key".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] +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) { + eprintln!("suspend_binding error for id '{}': {}", id, e); + return Err(e); + } + } + Ok(()) +} + +/// Re-register the binding after the user has finished editing. +#[tauri::command] +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) { + eprintln!("resume_binding error for id '{}': {}", id, e); + return Err(e); + } + } + Ok(()) +} + fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { + // Validate human-level rules first + if let Err(e) = validate_shortcut_string(&binding.current_binding) { + eprintln!( + "_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::() { Ok(s) => s, - Err(e) => return Err(format!("Failed to parse shortcut: {}", e)), + Err(e) => { + let error_msg = format!( + "Failed to parse shortcut '{}': {}", + binding.current_binding, e + ); + eprintln!("_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); + eprintln!("_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(); @@ -197,7 +270,11 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S } } }) - .map_err(|e| format!("Couldn't register shortcut: {}", e))?; + .map_err(|e| { + let error_msg = format!("Couldn't register shortcut '{}': {}", binding.current_binding, e); + eprintln!("_register_shortcut registration error: {}", error_msg); + error_msg + })?; Ok(()) } @@ -205,12 +282,24 @@ fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), S fn _unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { let shortcut = match binding.current_binding.parse::() { Ok(s) => s, - Err(e) => return Err(format!("Failed to parse shortcut: {}", e)), + Err(e) => { + let error_msg = format!( + "Failed to parse shortcut '{}' for unregistration: {}", + binding.current_binding, e + ); + eprintln!("_unregister_shortcut parse error: {}", error_msg); + return Err(error_msg); + } }; - app.global_shortcut() - .unregister(shortcut) - .map_err(|e| format!("Failed to unregister shortcut: {}", e))?; + app.global_shortcut().unregister(shortcut).map_err(|e| { + let error_msg = format!( + "Failed to unregister shortcut '{}': {}", + binding.current_binding, e + ); + eprintln!("_unregister_shortcut error: {}", error_msg); + error_msg + })?; Ok(()) } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 689dc19..4c377f8 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -14,7 +14,7 @@ use std::thread; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIcon; -use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindowBuilder}; use tauri_plugin_clipboard_manager::ClipboardExt; /// Sends a paste command (Cmd+V or Ctrl+V) using platform-specific virtual key codes. @@ -82,13 +82,32 @@ pub enum TrayIconState { Transcribing, } +/// Gets the current system theme, defaulting to Dark if unavailable +fn get_current_theme(app: &AppHandle) -> Theme { + if let Some(main_window) = app.get_webview_window("main") { + main_window.theme().unwrap_or(Theme::Dark) + } else { + Theme::Dark + } +} + pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { let tray = app.state::(); + let theme = get_current_theme(app); - let icon_path = match icon { - TrayIconState::Idle => "resources/tray_idle.png", - TrayIconState::Recording => "resources/tray_recording.png", - TrayIconState::Transcribing => "resources/tray_transcribing.png", + let icon_path = match (theme, &icon) { + // Dark theme uses regular icons (lighter colored for visibility) + (Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", + (Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png", + (Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png", + // Light theme uses dark icons (darker colored for visibility) + (Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png", + (Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png", + (Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png", + // Fallback for any other theme variants + (_, TrayIconState::Idle) => "resources/tray_idle.png", + (_, TrayIconState::Recording) => "resources/tray_recording.png", + (_, TrayIconState::Transcribing) => "resources/tray_transcribing.png", }; let _ = tray.set_icon(Some( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 988dae3..e3a1ee0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Handy", - "version": "0.3.3", + "version": "0.3.5", "identifier": "com.pais.handy", "build": { "beforeDevCommand": "bun run dev", @@ -15,9 +15,9 @@ { "title": "Handy", "width": 540, - "height": 720, + "height": 700, "minWidth": 540, - "minHeight": 720, + "minHeight": 700, "resizable": true, "maximizable": false } diff --git a/src/App.tsx b/src/App.tsx index 99a670d..fbd1a45 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,12 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; import "./App.css"; -import { Settings } from "./components/settings/Settings"; -import HandyTextLogo from "./components/icons/HandyTextLogo"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; +import HandyTextLogo from "./components/icons/HandyTextLogo"; import Onboarding from "./components/onboarding"; -import { useState, useEffect } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { Settings } from "./components/settings/Settings"; +import { Toaster } from "sonner"; function App() { const [showOnboarding, setShowOnboarding] = useState(null); @@ -34,7 +35,7 @@ function App() { return (
- +
@@ -43,8 +44,9 @@ function App() { return (
+
- +
diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 6ea5459..2501ab0 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -1,10 +1,17 @@ import React, { useEffect, useState, useRef } from "react"; import { BindingResponseSchema, ShortcutBindingsMap } from "../../lib/types"; import { type } from "@tauri-apps/plugin-os"; -import { getKeyName } from "../../lib/utils/keyboard"; +import { + getKeyName, + formatKeyCombination, + normalizeKey, + type OSType, +} from "../../lib/utils/keyboard"; import ResetIcon from "../icons/ResetIcon"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; interface HandyShortcutProps { descriptionMode?: "inline" | "tooltip"; @@ -23,57 +30,42 @@ export const HandyShortcut: React.FC = ({ null, ); const [originalBinding, setOriginalBinding] = useState(""); - const [isMacOS, setIsMacOS] = useState(false); + const [osType, setOsType] = useState("unknown"); const shortcutRefs = useRef>(new Map()); const bindings = getSetting("bindings") || {}; - // Check if running on macOS + // Detect and store OS type useEffect(() => { - const checkOsType = async () => { + const detectOsType = async () => { try { - const osType = await type(); - setIsMacOS(osType === "macos"); + 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); - setIsMacOS(false); + setOsType("unknown"); } }; - checkOsType(); + detectOsType(); }, []); - // Normalize modifier keys (unify left/right variants) - const normalizeKey = (key: string): string => { - // Handle left/right variants of modifier keys - if (key.startsWith("left ") || key.startsWith("right ")) { - const parts = key.split(" "); - if (parts.length === 2) { - // Return just the modifier name without left/right prefix - return parts[1]; - } - } - return key; - }; - - // Format keys for macOS display - const formatMacOSKeys = (key: string): string => { - if (!isMacOS) return key; // Only format for macOS - - const keyMap: Record = { - alt: "option", - }; - - return keyMap[key.toLowerCase()] || key; - }; - - // Format a key combination for display - const formatKeyCombination = (combination: string): string => { - if (!isMacOS) return combination; // Only format for macOS - - return combination.split("+").map(formatMacOSKeys).join(" + "); - }; - useEffect(() => { // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; @@ -82,10 +74,34 @@ export const HandyShortcut: React.FC = ({ // Keyboard event listeners const handleKeyDown = async (e: KeyboardEvent) => { + if (e.repeat) return; // ignore auto-repeat + if (e.key === "Escape") { + // Cancel recording and restore original binding + if (editingShortcutId && originalBinding) { + try { + await updateBinding(editingShortcutId, originalBinding); + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } catch (error) { + console.error("Failed to restore original binding:", error); + toast.error("Failed to restore original shortcut"); + } + } else if (editingShortcutId) { + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } + setEditingShortcutId(null); + setKeyPressed([]); + setRecordedKeys([]); + setOriginalBinding(""); + return; + } e.preventDefault(); - // Get the key and normalize it (unify left/right modifiers) - const rawKey = getKeyName(e); + // Get the key with OS-specific naming and normalize it + const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); console.log("You pressed", rawKey, "normalized to", key); @@ -102,8 +118,8 @@ export const HandyShortcut: React.FC = ({ const handleKeyUp = async (e: KeyboardEvent) => { e.preventDefault(); - // Get the key and normalize it - const rawKey = getKeyName(e); + // Get the key with OS-specific naming and normalize it + const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); // Remove from currently pressed keys @@ -118,8 +134,26 @@ export const HandyShortcut: React.FC = ({ if (editingShortcutId && bindings[editingShortcutId]) { try { await updateBinding(editingShortcutId, newShortcut); + // Re-register the shortcut now that recording is finished + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); } catch (error) { console.error("Failed to change binding:", error); + toast.error(`Failed to set shortcut: ${error}`); + + // Reset to original binding on error + if (originalBinding) { + try { + await updateBinding(editingShortcutId, originalBinding); + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } catch (resetError) { + console.error("Failed to reset binding:", resetError); + toast.error("Failed to reset shortcut to original value"); + } + } } // Exit editing mode and reset states @@ -132,10 +166,25 @@ export const HandyShortcut: React.FC = ({ }; // Add click outside handler - const handleClickOutside = (e: MouseEvent) => { + const handleClickOutside = async (e: MouseEvent) => { const activeElement = shortcutRefs.current.get(editingShortcutId); if (activeElement && !activeElement.contains(e.target as Node)) { - // Cancel shortcut recording - the hook will handle rollback + // Cancel shortcut recording and restore original binding + if (editingShortcutId && originalBinding) { + try { + await updateBinding(editingShortcutId, originalBinding); + await invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } catch (error) { + console.error("Failed to restore original binding:", error); + toast.error("Failed to restore original shortcut"); + } + } else if (editingShortcutId) { + invoke("resume_binding", { id: editingShortcutId }).catch( + console.error, + ); + } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); @@ -159,12 +208,16 @@ export const HandyShortcut: React.FC = ({ bindings, originalBinding, updateBinding, + osType, ]); // Start recording a new shortcut - const startRecording = (id: string) => { + const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut + // Suspend current binding to avoid firing while recording + await invoke("suspend_binding", { id }).catch(console.error); + // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); setEditingShortcutId(id); @@ -173,15 +226,11 @@ export const HandyShortcut: React.FC = ({ }; // Format the current shortcut keys being recorded - const formatCurrentKeys = () => { + const formatCurrentKeys = (): string => { if (recordedKeys.length === 0) return "Press keys..."; - if (!isMacOS) { - return recordedKeys.join("+"); - } - - // Map each key to its macOS-friendly name for display - return recordedKeys.map(formatMacOSKeys).join(" + "); + // Use the same formatting as the display to ensure consistency + return formatKeyCombination(recordedKeys.join("+"), osType); }; // Store references to shortcut elements @@ -248,7 +297,7 @@ export const HandyShortcut: React.FC = ({ 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(primaryId)} > - {formatKeyCombination(primaryBinding.current_binding)} + {formatKeyCombination(primaryBinding.current_binding, osType)}
)}