Merge branch 'main' into tweak-colors

This commit is contained in:
Vlad Gerasimov 2025-08-03 10:56:24 +04:00
commit 7357baf672
18 changed files with 585 additions and 186 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -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"

100
src-tauri/Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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<ModelManager>>,
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<String, String> {
// Recommend small model for first-time users

View file

@ -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,

View file

@ -18,6 +18,8 @@ pub struct ModelInfo {
pub url: Option<String>,
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(())
}
}

View file

@ -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::<Shortcut>() {
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::<Shortcut>() {
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(())
}

View file

@ -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::<TrayIcon>();
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(

View file

@ -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
}

View file

@ -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<boolean | null>(null);
@ -34,7 +35,7 @@ function App() {
return (
<div className="min-h-screen flex flex-col w-full">
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
<HandyTextLogo width={300} />
<HandyTextLogo width={240} />
<Onboarding onModelSelected={handleModelSelected} />
</div>
</div>
@ -43,8 +44,9 @@ function App() {
return (
<div className="min-h-screen flex flex-col w-full">
<Toaster />
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
<HandyTextLogo width={300} />
<HandyTextLogo width={240} />
<AccessibilityPermissions />
<Settings />
</div>

View file

@ -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<HandyShortcutProps> = ({
null,
);
const [originalBinding, setOriginalBinding] = useState<string>("");
const [isMacOS, setIsMacOS] = useState<boolean>(false);
const [osType, setOsType] = useState<OSType>("unknown");
const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(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<string, string> = {
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<HandyShortcutProps> = ({
// 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<HandyShortcutProps> = ({
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<HandyShortcutProps> = ({
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<HandyShortcutProps> = ({
};
// 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<HandyShortcutProps> = ({
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<HandyShortcutProps> = ({
};
// 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<HandyShortcutProps> = ({
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)}
</div>
)}
<button

View file

@ -15,7 +15,7 @@ export const SettingsGroup: React.FC<SettingsGroupProps> = ({
<div className="space-y-2">
{title && (
<div className="px-4">
<h2 className="text-sm font-medium text-mid-gray uppercase tracking-wide">
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
{title}
</h2>
{description && (

View file

@ -51,6 +51,8 @@ export const ModelInfoSchema = z.object({
description: z.string(),
size_mb: z.number(),
is_downloaded: z.boolean(),
is_downloading: z.boolean(),
partial_size: z.number(),
});
export type ModelInfo = z.infer<typeof ModelInfoSchema>;

View file

@ -2,12 +2,17 @@
* Keyboard utility functions for handling keyboard events
*/
export type OSType = "macos" | "windows" | "linux" | "unknown";
/**
* Extract a consistent key name from a KeyboardEvent
* This function replaces the keycode library and provides better support
* for extended function keys (F14+) and cross-platform compatibility
* This function provides cross-platform keyboard event handling
* and returns key names appropriate for the target operating system
*/
export const getKeyName = (e: KeyboardEvent): string => {
export const getKeyName = (
e: KeyboardEvent,
osType: OSType = "unknown",
): string => {
// Handle special cases first
if (e.code) {
const code = e.code;
@ -32,18 +37,35 @@ export const getKeyName = (e: KeyboardEvent): string => {
return code.replace("Numpad", "numpad ").toLowerCase();
}
// Handle modifier keys - normalize left/right variants
// Handle modifier keys - OS-specific naming
const getModifierName = (baseModifier: string): string => {
switch (baseModifier) {
case "shift":
return "shift";
case "ctrl":
return osType === "macos" ? "ctrl" : "ctrl";
case "alt":
return osType === "macos" ? "option" : "alt";
case "meta":
// Windows key on Windows/Linux, Command key on Mac
if (osType === "macos") return "command";
return "super";
default:
return baseModifier;
}
};
const modifierMap: Record<string, string> = {
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
ControlRight: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "command",
MetaRight: "command",
OSLeft: "command",
OSRight: "command",
ShiftLeft: getModifierName("shift"),
ShiftRight: getModifierName("shift"),
ControlLeft: getModifierName("ctrl"),
ControlRight: getModifierName("ctrl"),
AltLeft: getModifierName("alt"),
AltRight: getModifierName("alt"),
MetaLeft: getModifierName("meta"),
MetaRight: getModifierName("meta"),
OSLeft: getModifierName("meta"),
OSRight: getModifierName("meta"),
CapsLock: "caps lock",
Tab: "tab",
Enter: "enter",
@ -103,13 +125,15 @@ export const getKeyName = (e: KeyboardEvent): string => {
if (e.key) {
const key = e.key;
// Handle special key names
// Handle special key names with OS-specific formatting
const keyMap: Record<string, string> = {
Control: "ctrl",
Alt: "alt",
Control: osType === "macos" ? "ctrl" : "ctrl",
Alt: osType === "macos" ? "option" : "alt",
Shift: "shift",
Meta: "command",
OS: "command",
Meta:
osType === "macos" ? "command" : osType === "windows" ? "win" : "super",
OS:
osType === "macos" ? "command" : osType === "windows" ? "win" : "super",
CapsLock: "caps lock",
ArrowUp: "up",
ArrowDown: "down",
@ -129,3 +153,31 @@ export const getKeyName = (e: KeyboardEvent): string => {
// Last resort fallback
return `unknown-${e.keyCode || e.which || 0}`;
};
/**
* Get display-friendly key combination string for the current OS
* Returns basic plus-separated format with correct platform key names
*/
export const formatKeyCombination = (
combination: string,
osType: OSType,
): string => {
// Simply return the combination as-is since getKeyName already provides
// the correct platform-specific key names
return combination;
};
/**
* Normalize modifier keys to handle left/right variants
*/
export 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;
};