diff --git a/CLAUDE.md b/CLAUDE.md index b6f625a..9c717e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,35 @@ Use conventional commits: - `refactor:` code refactoring - `chore:` maintenance +## CLI Parameters + +Handy supports command-line parameters on all platforms for integration with scripts, window managers, and autostart configurations. + +**Implementation files:** + +- `src-tauri/src/cli.rs` - CLI argument definitions (clap derive) +- `src-tauri/src/main.rs` - Argument parsing before Tauri launch +- `src-tauri/src/lib.rs` - Applying CLI overrides (setup closure + single-instance callback) +- `src-tauri/src/signal_handle.rs` - `send_transcription_input()` reusable function + +**Available flags:** + +| Flag | Description | +| ------------------------ | ---------------------------------------------------------------------------------- | +| `--toggle-transcription` | Toggle recording on/off on a running instance (via `tauri_plugin_single_instance`) | +| `--toggle-post-process` | Toggle recording with post-processing on/off on a running instance | +| `--cancel` | Cancel the current operation on a running instance | +| `--start-hidden` | Launch without showing the main window (tray icon still visible) | +| `--no-tray` | Launch without the system tray icon (closing window quits the app) | +| `--debug` | Enable debug mode with verbose (Trace) logging | + +**Key design decisions:** + +- CLI flags are runtime-only overrides — they do NOT modify persisted settings +- Remote control flags (`--toggle-transcription`, `--toggle-post-process`, `--cancel`) work by launching a second instance that sends its args to the running instance via `tauri_plugin_single_instance`, then exits +- `send_transcription_input()` in `signal_handle.rs` is shared between signal handlers and CLI to avoid code duplication +- `CliArgs` is stored in Tauri managed state (`.manage()`) so it's accessible in `on_window_event` and other handlers + ## Debug Mode Access debug features: `Cmd+Shift+D` (macOS) or `Ctrl+Shift+D` (Windows/Linux) diff --git a/README.md b/README.md index b0405cf..6e592d7 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,39 @@ Handy includes an advanced debug mode for development and troubleshooting. Acces - **macOS**: `Cmd+Shift+D` - **Windows/Linux**: `Ctrl+Shift+D` +### CLI Parameters + +Handy supports command-line flags for controlling a running instance and customizing startup behavior. These work on all platforms (macOS, Windows, Linux). + +**Remote control flags** (sent to an already-running instance via the single-instance plugin): + +```bash +handy --toggle-transcription # Toggle recording on/off +handy --toggle-post-process # Toggle recording with post-processing on/off +handy --cancel # Cancel the current operation +``` + +**Startup flags:** + +```bash +handy --start-hidden # Start without showing the main window +handy --no-tray # Start without the system tray icon +handy --debug # Enable debug mode with verbose logging +handy --help # Show all available flags +``` + +Flags can be combined for autostart scenarios: + +```bash +handy --start-hidden --no-tray +``` + +> **macOS tip:** When Handy is installed as an app bundle, invoke the binary directly: +> +> ```bash +> /Applications/Handy.app/Contents/MacOS/Handy --toggle-transcription +> ``` + ## Known Issues & Current Limitations This project is actively being developed and has some [known issues](https://github.com/cjpais/Handy/issues). We believe in transparency about the current state: @@ -118,7 +151,39 @@ Without these tools, Handy falls back to enigo which may have limited compatibil - The recording overlay is disabled by default on Linux (`Overlay Position: None`) because certain compositors treat it as the active window. When the overlay is visible it can steal focus, which prevents Handy from pasting back into the application that triggered transcription. If you enable the overlay anyway, be aware that clipboard-based pasting might fail or end up in the wrong window. - If you are having trouble with the app, running with the environment variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` may help -- You can manage global shortcuts outside of Handy and still control the app via signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: +- **Global keyboard shortcuts (Wayland):** On Wayland, system-level shortcuts must be configured through your desktop environment or window manager. Use the [CLI flags](#cli-parameters) as the command for your custom shortcut. + + **GNOME:** + 1. Open **Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts** + 2. Click the **+** button to add a new shortcut + 3. Set the **Name** to `Toggle Handy Transcription` + 4. Set the **Command** to `handy --toggle-transcription` + 5. Click **Set Shortcut** and press your desired key combination (e.g., `Super+O`) + + **KDE Plasma:** + 1. Open **System Settings > Shortcuts > Custom Shortcuts** + 2. Click **Edit > New > Global Shortcut > Command/URL** + 3. Name it `Toggle Handy Transcription` + 4. In the **Trigger** tab, set your desired key combination + 5. In the **Action** tab, set the command to `handy --toggle-transcription` + + **Sway / i3:** + + Add to your config file (`~/.config/sway/config` or `~/.config/i3/config`): + + ```ini + bindsym $mod+o exec handy --toggle-transcription + ``` + + **Hyprland:** + + Add to your config file (`~/.config/hypr/hyprland.conf`): + + ```ini + bind = $mainMod, O, exec, handy --toggle-transcription + ``` + +- You can also manage global shortcuts outside of Handy via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: | Signal | Action | Example | | --------- | ----------------------------------------- | ---------------------- | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b16d8e..a4e2665 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2445,6 +2445,7 @@ version = "0.7.5" dependencies = [ "anyhow", "chrono", + "clap", "cpal", "enigo", "env_filter", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04addb5..f5f5d6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -71,6 +71,7 @@ flate2 = "1.0" transcribe-rs = { version = "0.2.5", features = ["whisper", "parakeet", "moonshine", "sense_voice"] } handy-keys = "0.2.0" ferrous-opencc = "0.2.3" +clap = { version = "4", features = ["derive"] } specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs new file mode 100644 index 0000000..24a8a54 --- /dev/null +++ b/src-tauri/src/cli.rs @@ -0,0 +1,29 @@ +use clap::Parser; + +#[derive(Parser, Debug, Clone, Default)] +#[command(name = "handy", about = "Handy - Speech to Text")] +pub struct CliArgs { + /// Start with the main window hidden + #[arg(long)] + pub start_hidden: bool, + + /// Disable the system tray icon + #[arg(long)] + pub no_tray: bool, + + /// Toggle transcription on/off (sent to running instance) + #[arg(long)] + pub toggle_transcription: bool, + + /// Toggle transcription with post-processing on/off (sent to running instance) + #[arg(long)] + pub toggle_post_process: bool, + + /// Cancel the current operation (sent to running instance) + #[arg(long)] + pub cancel: bool, + + /// Enable debug mode with verbose logging + #[arg(long)] + pub debug: bool, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b280c1..5fedea4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod actions; mod apple_intelligence; mod audio_feedback; pub mod audio_toolkit; +pub mod cli; mod clipboard; mod commands; mod helpers; @@ -17,6 +18,8 @@ mod transcription_coordinator; mod tray; mod tray_i18n; mod utils; + +pub use cli::CliArgs; use specta_typescript::{BigIntExportBehavior, Typescript}; use tauri_specta::{collect_commands, Builder}; @@ -247,7 +250,7 @@ fn trigger_update_check(app: AppHandle) -> Result<(), String> { } #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { +pub fn run(cli_args: CliArgs) { // Parse console logging directives from RUST_LOG, falling back to info-level logging // when the variable is unset let console_filter = build_console_filter(); @@ -384,8 +387,16 @@ pub fn run() { } builder - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - show_main_window(app); + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + if args.iter().any(|a| a == "--toggle-transcription") { + signal_handle::send_transcription_input(app, "transcribe", "CLI"); + } else if args.iter().any(|a| a == "--toggle-post-process") { + signal_handle::send_transcription_input(app, "transcribe_with_post_process", "CLI"); + } else if args.iter().any(|a| a == "--cancel") { + crate::utils::cancel_current_operation(app); + } else { + show_main_window(app); + } })) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) @@ -400,8 +411,16 @@ pub fn run() { MacosLauncher::LaunchAgent, Some(vec![]), )) + .manage(cli_args.clone()) .setup(move |app| { - let settings = get_settings(&app.handle()); + let mut settings = get_settings(&app.handle()); + + // CLI --debug flag overrides debug_mode and log level (runtime-only, not persisted) + if cli_args.debug { + settings.debug_mode = true; + settings.log_level = settings::LogLevel::Trace; + } + let tauri_log_level: tauri_plugin_log::LogLevel = settings.log_level.into(); let file_log_level: log::Level = tauri_log_level.into(); // Store the file log level in the atomic for the filter to use @@ -411,8 +430,15 @@ pub fn run() { initialize_core_logic(&app_handle); + // Hide tray icon if --no-tray was passed + if cli_args.no_tray { + tray::set_tray_visibility(&app_handle, false); + } + // Show main window only if not starting hidden - if !settings.start_hidden { + // CLI --start-hidden flag overrides the setting + let should_hide = settings.start_hidden || cli_args.start_hidden; + if !should_hide { if let Some(main_window) = app_handle.get_webview_window("main") { main_window.show().unwrap(); main_window.set_focus().unwrap(); @@ -424,8 +450,9 @@ pub fn run() { .on_window_event(|window, event| match event { tauri::WindowEvent::CloseRequested { api, .. } => { let settings = get_settings(&window.app_handle()); - // If tray icon is hidden, quit the app - if !settings.show_tray_icon { + let cli = window.app_handle().state::(); + // If tray icon is hidden (via setting or --no-tray flag), quit the app + if !settings.show_tray_icon || cli.no_tray { window.app_handle().exit(0); return; } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2175d34..2936259 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,12 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use clap::Parser; +use handy_app_lib::CliArgs; + fn main() { + let cli_args = CliArgs::parse(); + #[cfg(target_os = "linux")] { // DMABUF renderer causes crashes on various GPU/display server configurations @@ -9,5 +14,5 @@ fn main() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } - handy_app_lib::run() + handy_app_lib::run(cli_args) } diff --git a/src-tauri/src/signal_handle.rs b/src-tauri/src/signal_handle.rs index 1113eb5..12a0d84 100644 --- a/src-tauri/src/signal_handle.rs +++ b/src-tauri/src/signal_handle.rs @@ -1,12 +1,23 @@ use crate::TranscriptionCoordinator; use log::{debug, warn}; -use std::thread; use tauri::{AppHandle, Manager}; #[cfg(unix)] use signal_hook::consts::{SIGUSR1, SIGUSR2}; #[cfg(unix)] use signal_hook::iterator::Signals; +#[cfg(unix)] +use std::thread; + +/// Send a transcription input to the coordinator. +/// Used by signal handlers, CLI flags, and any other external trigger. +pub fn send_transcription_input(app: &AppHandle, binding_id: &str, source: &str) { + if let Some(c) = app.try_state::() { + c.send_input(binding_id, source, true, false); + } else { + warn!("TranscriptionCoordinator not initialized"); + } +} #[cfg(unix)] pub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) { @@ -19,11 +30,7 @@ pub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) { _ => continue, }; debug!("Received {signal_name}"); - if let Some(c) = app_handle.try_state::() { - c.send_input(binding_id, signal_name, true, false); - } else { - warn!("TranscriptionCoordinator not initialized"); - } + send_transcription_input(&app_handle, binding_id, signal_name); } }); }