From a576847abe1f0dc46602edacf39151506ee2ab52 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 22 Jan 2026 12:13:07 +0100 Subject: [PATCH 1/4] Replace inotify with notify crate for device hotplug detection This switches the device hotplug detection in the evdev listener from using inotify directly to using the notify crate, which abstracts filesystem watching across platforms. Benefits: - Cleaner dependency (notify is already used for status --follow) - The notify crate is better maintained and more widely used - Prepares codebase for future cross-platform support The notify crate uses inotify internally on Linux, so behavior is unchanged on the primary platform. --- Cargo.lock | 22 +---- Cargo.toml | 3 +- src/hotkey/evdev_listener.rs | 177 +++++++++++++++++++++++++---------- 3 files changed, 129 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3aeec73..80377e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,12 +734,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - [[package]] name = "getrandom" version = "0.2.16" @@ -950,19 +944,6 @@ dependencies = [ "libc", ] -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "futures-core", - "inotify-sys", - "libc", - "tokio", -] - [[package]] name = "inotify-sys" version = "0.1.5" @@ -1356,7 +1337,7 @@ dependencies = [ "crossbeam-channel", "filetime", "fsevent-sys", - "inotify 0.9.6", + "inotify", "kqueue", "libc", "log", @@ -2617,7 +2598,6 @@ dependencies = [ "directories", "evdev", "hound", - "inotify 0.10.2", "libc", "nix 0.29.0", "notify", diff --git a/Cargo.toml b/Cargo.toml index df16aa1..e220cc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ async-trait = "0.1" # Input handling (evdev for kernel-level key events) evdev = "0.12" libc = "0.2" -inotify = "0.10" # Watch /dev/input for device hotplug nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC # Audio capture @@ -64,7 +63,7 @@ parakeet-rs = { version = "0.2.9", optional = true } # CPU count for thread detection num_cpus = "1.16" -# File watching for status --follow +# File and device watching (status --follow, device hotplug) notify = "6" # Single instance check diff --git a/src/hotkey/evdev_listener.rs b/src/hotkey/evdev_listener.rs index fdb2ed4..2013a62 100644 --- a/src/hotkey/evdev_listener.rs +++ b/src/hotkey/evdev_listener.rs @@ -3,8 +3,9 @@ //! Uses the Linux evdev interface to detect key presses at the kernel level. //! This works on all Wayland compositors because it bypasses the display server. //! -//! Uses inotify to detect device changes (hotplug, screenlock, suspend/resume) -//! and automatically re-enumerates devices when needed. +//! Uses the notify crate to detect device changes (hotplug, screenlock, suspend/resume) +//! and automatically re-enumerates devices when needed. The notify crate provides +//! cross-platform filesystem watching (inotify on Linux, FSEvents on macOS). //! //! The user must be in the 'input' group to access /dev/input/* devices. @@ -12,10 +13,11 @@ use super::{HotkeyEvent, HotkeyListener}; use crate::config::HotkeyConfig; use crate::error::HotkeyError; use evdev::{Device, InputEventKind, Key}; -use inotify::{Inotify, WatchMask}; +use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{HashMap, HashSet}; use std::os::unix::io::AsRawFd; use std::path::PathBuf; +use std::sync::mpsc as std_mpsc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; @@ -119,35 +121,49 @@ impl HotkeyListener for EvdevListener { } } -/// Manages input devices with hotplug detection via inotify +/// Manages input devices with hotplug detection via notify crate struct DeviceManager { /// Map of device path to opened device devices: HashMap, - /// inotify instance watching /dev/input - inotify: Inotify, - /// Buffer for inotify events - inotify_buffer: [u8; 1024], + /// Filesystem watcher for /dev/input (kept alive to maintain watch) + #[allow(dead_code)] + watcher: RecommendedWatcher, + /// Receiver for filesystem events + fs_event_rx: std_mpsc::Receiver>, /// Last time we did a full validation last_validation: Instant, } impl DeviceManager { - /// Create a new device manager with inotify watcher + /// Create a new device manager with filesystem watcher fn new() -> Result { - let inotify = Inotify::init().map_err(|e| { - HotkeyError::DeviceAccess(format!("Failed to initialize inotify: {}", e)) + // Set up channel for filesystem events + let (tx, rx) = std_mpsc::channel(); + + // Create filesystem watcher using the notify crate + // On Linux this uses inotify internally, on macOS it uses FSEvents + let mut watcher = RecommendedWatcher::new( + move |res| { + let _ = tx.send(res); + }, + NotifyConfig::default(), + ) + .map_err(|e| { + HotkeyError::DeviceAccess(format!("Failed to initialize filesystem watcher: {}", e)) })?; // Watch /dev/input for device creation and deletion - inotify - .watches() - .add("/dev/input", WatchMask::CREATE | WatchMask::DELETE) + watcher + .watch( + std::path::Path::new("/dev/input"), + RecursiveMode::NonRecursive, + ) .map_err(|e| HotkeyError::DeviceAccess(format!("Failed to watch /dev/input: {}", e)))?; let mut manager = Self { devices: HashMap::new(), - inotify, - inotify_buffer: [0u8; 1024], + watcher, + fs_event_rx: rx, last_validation: Instant::now(), }; @@ -233,45 +249,51 @@ impl DeviceManager { } } - /// Check inotify for device changes (non-blocking) + /// Check for device changes (non-blocking) /// Returns true if devices changed fn check_for_device_changes(&mut self) -> bool { - // Set inotify to non-blocking for this check - let fd = self.inotify.as_raw_fd(); - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags != -1 { - libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } + let mut changed = false; - let events = match self.inotify.read_events(&mut self.inotify_buffer) { - Ok(events) => events, - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - return false; - } - Err(e) => { - tracing::warn!("inotify read error: {}", e); - return false; - } - }; + // Drain all pending filesystem events (non-blocking) + loop { + match self.fs_event_rx.try_recv() { + Ok(Ok(event)) => { + // Check if this is a create or remove event for an event* device + for path in event.paths { + let is_event_device = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + + if !is_event_device { + continue; + } - let mut changed = false; - for event in events { - if let Some(name) = event.name { - let name_str = name.to_string_lossy(); - if name_str.starts_with("event") { - let path = PathBuf::from("/dev/input").join(&*name_str); - - if event.mask.contains(inotify::EventMask::CREATE) { - tracing::debug!("Device created: {:?}", path); - changed = true; - } else if event.mask.contains(inotify::EventMask::DELETE) { - tracing::debug!("Device removed: {:?}", path); - self.devices.remove(&path); - changed = true; + match event.kind { + notify::EventKind::Create(_) => { + tracing::debug!("Device created: {:?}", path); + changed = true; + } + notify::EventKind::Remove(_) => { + tracing::debug!("Device removed: {:?}", path); + self.devices.remove(&path); + changed = true; + } + _ => {} + } } } + Ok(Err(e)) => { + tracing::warn!("Filesystem watch error: {}", e); + } + Err(std_mpsc::TryRecvError::Empty) => { + // No more events pending + break; + } + Err(std_mpsc::TryRecvError::Disconnected) => { + tracing::warn!("Filesystem watcher disconnected"); + break; + } } } @@ -417,7 +439,7 @@ fn evdev_listener_loop( Err(oneshot::error::TryRecvError::Empty) => {} } - // Check inotify for device changes + // Check for device changes (filesystem events) if manager.check_for_device_changes() { // Clear state when devices change active_modifiers.clear(); @@ -661,4 +683,59 @@ mod tests { fn test_parse_key_name_error() { assert!(parse_key_name("INVALID_KEY_NAME").is_err()); } + + #[test] + fn test_is_event_device_filter() { + // Test the device path filtering logic used in check_for_device_changes + let test_cases = [ + ("/dev/input/event0", true), + ("/dev/input/event123", true), + ("/dev/input/mouse0", false), + ("/dev/input/js0", false), + ("/dev/input/by-id/usb-keyboard", false), + ("/dev/input/eventfoo", true), // starts with event + ]; + + for (path_str, expected) in test_cases { + let path = PathBuf::from(path_str); + let is_event = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + assert_eq!( + is_event, expected, + "Path {} should be event device: {}", + path_str, expected + ); + } + } + + #[test] + fn test_notify_event_kind_matching() { + // Verify we correctly identify Create and Remove event kinds + // This tests the pattern matching used in check_for_device_changes + use notify::event::{CreateKind, RemoveKind}; + + let create_event = notify::EventKind::Create(CreateKind::File); + let remove_event = notify::EventKind::Remove(RemoveKind::File); + let modify_event = notify::EventKind::Modify(notify::event::ModifyKind::Any); + + // Test Create matching + let is_create = matches!(create_event, notify::EventKind::Create(_)); + assert!(is_create, "Create event should match Create pattern"); + + // Test Remove matching + let is_remove = matches!(remove_event, notify::EventKind::Remove(_)); + assert!(is_remove, "Remove event should match Remove pattern"); + + // Test that Modify does not match Create or Remove + let is_create_or_remove = matches!( + modify_event, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) + ); + assert!( + !is_create_or_remove, + "Modify event should not match Create or Remove" + ); + } } From 29c9ca6fafdd05a91636bf4df2460ba01ee5958a Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 22 Jan 2026 12:16:22 +0100 Subject: [PATCH 2/4] Add macOS support This adds full support for running voxtype on macOS: **Hotkey capture:** - CGEventTap-based global hotkey detection (requires Accessibility permissions) - FN/Globe key support via SecondaryFn flag detection - Platform-specific default: FN on macOS, SCROLLLOCK on Linux **Text output:** - CGEvent-based text injection for typing transcribed text - Clipboard support via pbcopy/pbpaste **Build configuration:** - Conditional compilation for platform-specific code - evdev moved to Linux-only dependency - core-graphics and core-foundation added for macOS **Other changes:** - cfg guards for Linux-specific code (evdev, inotify, .init_array) - Cross-platform test using printf instead of echo -n - Documentation updates for macOS options --- Cargo.lock | 324 ++++++++++----- Cargo.toml | 12 +- src/cli.rs | 43 +- src/config.rs | 25 +- src/cpu.rs | 12 +- src/daemon.rs | 23 +- src/error.rs | 4 + src/hotkey/macos.rs | 784 +++++++++++++++++++++++++++++++++++++ src/hotkey/mod.rs | 65 ++- src/main.rs | 199 +++++++++- src/output/cgevent.rs | 546 ++++++++++++++++++++++++++ src/output/mod.rs | 100 +++-- src/output/post_process.rs | 4 +- src/setup/mod.rs | 83 ++-- 14 files changed, 1987 insertions(+), 237 deletions(-) create mode 100644 src/hotkey/macos.rs create mode 100644 src/output/cgevent.rs diff --git a/Cargo.lock b/Cargo.lock index 80377e3..20ffa48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,9 +140,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bindgen" @@ -208,9 +208,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.47" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -295,9 +295,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -319,9 +319,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clap_mangen" @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -383,12 +383,46 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -657,27 +691,26 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -695,7 +728,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -704,6 +758,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -734,11 +794,36 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -783,9 +868,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hmac-sha256" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" +checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d" [[package]] name = "hound" @@ -925,9 +1010,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -1017,9 +1102,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1053,9 +1138,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -1069,13 +1154,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.7.0", ] [[package]] @@ -1101,15 +1186,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lzma-rust2" -version = "0.15.6" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" [[package]] name = "mach2" @@ -1200,9 +1285,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1250,9 +1335,9 @@ dependencies = [ [[package]] name = "ndarray" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7c9125e8f6f10c9da3aad044cc918cf8784fa34de857b1aa68038eb05a50a9" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -1490,7 +1575,7 @@ checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -1590,7 +1675,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1633,6 +1718,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1697,23 +1788,23 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1752,9 +1843,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -1805,13 +1896,22 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -1853,7 +1953,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1898,9 +1998,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -1926,18 +2026,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -1987,7 +2087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -2072,10 +2172,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2085,6 +2186,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -2156,9 +2263,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2184,9 +2291,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -2206,11 +2313,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2226,9 +2333,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2281,7 +2388,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -2289,13 +2396,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.0", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -2338,9 +2445,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -2361,21 +2468,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] @@ -2388,9 +2495,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2410,9 +2517,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2431,9 +2538,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -2594,6 +2701,8 @@ dependencies = [ "async-trait", "clap", "clap_mangen", + "core-foundation 0.10.1", + "core-graphics", "cpal", "directories", "evdev", @@ -2635,18 +2744,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -2657,11 +2766,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -2670,9 +2780,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2680,9 +2790,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -2693,18 +2803,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -3124,9 +3234,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -3255,6 +3365,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml index e220cc3..f8cbe7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,7 @@ regex = "1" # Async traits async-trait = "0.1" -# Input handling (evdev for kernel-level key events) -evdev = "0.12" +# Cross-platform system interface libc = "0.2" nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC @@ -69,6 +68,15 @@ notify = "6" # Single instance check pidlock = "0.1" +# Linux-specific dependencies +[target.'cfg(target_os = "linux")'.dependencies] +evdev = "0.12" # Input handling (kernel-level key events) + +# macOS-specific dependencies +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.24" # CGEvent for hotkey capture and text injection +core-foundation = "0.10" # Core Foundation types + [features] default = [] gpu-vulkan = ["whisper-rs/vulkan"] diff --git a/src/cli.rs b/src/cli.rs index 891bafb..e154ad5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -797,7 +797,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file=out.txt"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("out.txt")); } _ => panic!("Expected Record command"), @@ -821,7 +824,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("")); // Empty string means use config path } _ => panic!("Expected Record command"), @@ -845,7 +851,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file=/tmp/output.txt"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("/tmp/output.txt")); } _ => panic!("Expected Record command"), @@ -904,17 +913,9 @@ mod tests { #[test] fn test_record_start_file_mutually_exclusive_with_paste() { - let result = Cli::try_parse_from([ - "voxtype", - "record", - "start", - "--file=out.txt", - "--paste", - ]); - assert!( - result.is_err(), - "Should not allow both --file and --paste" - ); + let result = + Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--paste"]); + assert!(result.is_err(), "Should not allow both --file and --paste"); } #[test] @@ -934,17 +935,9 @@ mod tests { #[test] fn test_record_start_file_mutually_exclusive_with_type() { - let result = Cli::try_parse_from([ - "voxtype", - "record", - "start", - "--file=out.txt", - "--type", - ]); - assert!( - result.is_err(), - "Should not allow both --file and --type" - ); + let result = + Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--type"]); + assert!(result.is_err(), "Should not allow both --file and --type"); } #[test] diff --git a/src/config.rs b/src/config.rs index 9cf9594..8de0f5b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,9 +26,11 @@ state_file = "auto" [hotkey] # Key to hold for push-to-talk -# Common choices: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24 -# Use `evtest` to find key names for your keyboard -key = "SCROLLLOCK" +# Default: FN/Globe on macOS, SCROLLLOCK on Linux +# macOS options: FN/GLOBE, RIGHTOPTION, CAPSLOCK, F13-F20 +# Linux options: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24 +# key = "FN" # macOS default +# key = "SCROLLLOCK" # Linux default # Optional modifier keys that must also be held # Example: modifiers = ["LEFTCTRL", "LEFTALT"] @@ -295,8 +297,10 @@ pub struct Config { /// Hotkey detection configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HotkeyConfig { - /// Key name (evdev KEY_* constant name, without the KEY_ prefix) - /// Examples: "SCROLLLOCK", "RIGHTALT", "PAUSE", "F24" + /// Key name (without KEY_ prefix) + /// Default: "FN" on macOS, "SCROLLLOCK" on Linux + /// macOS: "FN", "GLOBE", "RIGHTOPTION", "CAPSLOCK", "F13"-"F20" + /// Linux: "SCROLLLOCK", "PAUSE", "RIGHTALT", "F13"-"F24" #[serde(default = "default_hotkey_key")] pub key: String, @@ -362,7 +366,14 @@ pub struct AudioFeedbackConfig { } fn default_hotkey_key() -> String { - "SCROLLLOCK".to_string() + #[cfg(target_os = "macos")] + { + "FN".to_string() + } + #[cfg(not(target_os = "macos"))] + { + "SCROLLLOCK".to_string() + } } fn default_sound_theme() -> String { @@ -887,7 +898,7 @@ pub struct NotificationConfig { pub on_recording_stop: bool, /// Notify with transcribed text after transcription completes - #[serde(default = "default_true")] + #[serde(default)] pub on_transcription: bool, /// Show engine icon in notification title (🦜 for Parakeet, 🗣️ for Whisper) diff --git a/src/cpu.rs b/src/cpu.rs index c061af2..2f32533 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -4,17 +4,21 @@ //! particularly in virtualized environments where the hypervisor may not //! expose all host CPU features. //! -//! The SIGILL handler is installed via a .init_array constructor, which runs -//! before main() - this is critical because AVX-512 instructions can appear -//! in library initialization code, before our Rust main() even starts. +//! On Linux, the SIGILL handler is installed via a .init_array constructor, +//! which runs before main() - this is critical because AVX-512 instructions +//! can appear in library initialization code, before our Rust main() even starts. +//! +//! On macOS, this functionality is not needed as macOS builds target different +//! CPU instruction sets. use std::sync::atomic::{AtomicBool, Ordering}; static SIGILL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); -/// Constructor function that runs before main() via .init_array +/// Constructor function that runs before main() via .init_array (Linux only) /// This ensures the SIGILL handler is installed before any library /// initialization code that might use unsupported instructions. +#[cfg(target_os = "linux")] #[used] #[link_section = ".init_array"] static INIT_SIGILL_HANDLER: extern "C" fn() = { diff --git a/src/daemon.rs b/src/daemon.rs index 84d010e..6d81c84 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -458,23 +458,23 @@ impl Daemon { if let Some(t) = transcriber { self.transcription_task = Some(tokio::task::spawn_blocking(move || t.transcribe(&samples))); - return true; + true } else { tracing::error!("No transcriber available"); self.play_feedback(SoundEvent::Error); self.reset_to_idle(state).await; - return false; + false } } Err(e) => { tracing::warn!("Recording error: {}", e); self.reset_to_idle(state).await; - return false; + false } } } else { self.reset_to_idle(state).await; - return false; + false } } @@ -585,18 +585,15 @@ impl Daemon { }; let file_mode = &self.config.output.file_mode; - match write_transcription_to_file(&output_path, &final_text, file_mode).await + match write_transcription_to_file(&output_path, &final_text, file_mode) + .await { Ok(()) => { let mode_str = match file_mode { FileMode::Overwrite => "wrote", FileMode::Append => "appended", }; - tracing::info!( - "{} transcription to {:?}", - mode_str, - output_path - ); + tracing::info!("{} transcription to {:?}", mode_str, output_path); } Err(e) => { tracing::error!( @@ -718,8 +715,7 @@ impl Daemon { return Err(crate::error::VoxtypeError::Config(format!( "Another voxtype instance is already running (lock error: {:?})", e - )) - .into()); + ))); } } @@ -1688,7 +1684,8 @@ mod tests { // Should not panic }); } - + + #[allow(dead_code)] fn test_pidlock_acquisition_succeeds() { with_test_runtime_dir(|dir| { let lock_path = dir.join("voxtype.lock"); diff --git a/src/error.rs b/src/error.rs index 75be313..6fe50bd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,6 +41,9 @@ pub enum HotkeyError { #[error("evdev error: {0}")] Evdev(String), + + #[error("Hotkey detection not supported: {0}")] + NotSupported(String), } /// Errors related to audio capture @@ -127,6 +130,7 @@ pub enum OutputError { /// Result type alias using VoxtypeError pub type Result = std::result::Result; +#[cfg(target_os = "linux")] impl From for HotkeyError { fn from(e: evdev::Error) -> Self { HotkeyError::Evdev(e.to_string()) diff --git a/src/hotkey/macos.rs b/src/hotkey/macos.rs new file mode 100644 index 0000000..368daeb --- /dev/null +++ b/src/hotkey/macos.rs @@ -0,0 +1,784 @@ +//! macOS-based hotkey listener using CGEventTap +//! +//! Uses the macOS Quartz Event Services (CGEventTap) to capture global key events. +//! For the FN/Globe key, monitors the SecondaryFn modifier flag changes. +//! +//! This approach requires Accessibility permissions to be granted to the application. +//! The user must grant Accessibility access in System Preferences > Security & Privacy > +//! Privacy > Accessibility for voxtype to receive global key events. + +use super::{HotkeyEvent, HotkeyListener}; +use crate::config::HotkeyConfig; +use crate::error::HotkeyError; +use core_foundation::runloop::{kCFRunLoopCommonModes, kCFRunLoopDefaultMode, CFRunLoop}; +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, + CGEventType, EventField, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc as std_mpsc; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot}; + +/// macOS virtual key codes +/// These are defined in Carbon HIToolbox Events.h (kVK_* constants) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +#[allow(non_camel_case_types)] +pub enum VirtualKeyCode { + // Letter keys (kVK_ANSI_*) + KEY_A = 0x00, + KEY_S = 0x01, + KEY_D = 0x02, + KEY_F = 0x03, + KEY_H = 0x04, + KEY_G = 0x05, + KEY_Z = 0x06, + KEY_X = 0x07, + KEY_C = 0x08, + KEY_V = 0x09, + KEY_B = 0x0B, + KEY_Q = 0x0C, + KEY_W = 0x0D, + KEY_E = 0x0E, + KEY_R = 0x0F, + KEY_Y = 0x10, + KEY_T = 0x11, + KEY_O = 0x1F, + KEY_U = 0x20, + KEY_I = 0x22, + KEY_P = 0x23, + KEY_L = 0x25, + KEY_J = 0x26, + KEY_K = 0x28, + KEY_N = 0x2D, + KEY_M = 0x2E, + + // Number keys (kVK_ANSI_*) + KEY_1 = 0x12, + KEY_2 = 0x13, + KEY_3 = 0x14, + KEY_4 = 0x15, + KEY_5 = 0x17, + KEY_6 = 0x16, + KEY_7 = 0x1A, + KEY_8 = 0x1C, + KEY_9 = 0x19, + KEY_0 = 0x1D, + + // Function keys (kVK_F*) + KEY_F1 = 0x7A, + KEY_F2 = 0x78, + KEY_F3 = 0x63, + KEY_F4 = 0x76, + KEY_F5 = 0x60, + KEY_F6 = 0x61, + KEY_F7 = 0x62, + KEY_F8 = 0x64, + KEY_F9 = 0x65, + KEY_F10 = 0x6D, + KEY_F11 = 0x67, + KEY_F12 = 0x6F, + KEY_F13 = 0x69, + KEY_F14 = 0x6B, + KEY_F15 = 0x71, + KEY_F16 = 0x6A, + KEY_F17 = 0x40, + KEY_F18 = 0x4F, + KEY_F19 = 0x50, + KEY_F20 = 0x5A, + + // Modifier keys (kVK_*) + KEY_CAPSLOCK = 0x39, + KEY_SHIFT = 0x38, + KEY_RIGHTSHIFT = 0x3C, + KEY_CONTROL = 0x3B, + KEY_RIGHTCONTROL = 0x3E, + KEY_OPTION = 0x3A, // Left Alt/Option + KEY_RIGHTOPTION = 0x3D, // Right Alt/Option + KEY_COMMAND = 0x37, // Left Command + KEY_RIGHTCOMMAND = 0x36, + KEY_FN = 0x3F, + + // Special keys + KEY_RETURN = 0x24, + KEY_TAB = 0x30, + KEY_SPACE = 0x31, + KEY_DELETE = 0x33, // Backspace + KEY_ESCAPE = 0x35, + KEY_FORWARDDELETE = 0x75, + KEY_HOME = 0x73, + KEY_END = 0x77, + KEY_PAGEUP = 0x74, + KEY_PAGEDOWN = 0x79, + + // Arrow keys + KEY_LEFTARROW = 0x7B, + KEY_RIGHTARROW = 0x7C, + KEY_DOWNARROW = 0x7D, + KEY_UPARROW = 0x7E, + + // Misc (kVK_ANSI_*) + KEY_GRAVE = 0x32, // ` or ~ + KEY_MINUS = 0x1B, + KEY_EQUAL = 0x18, + KEY_LEFTBRACKET = 0x21, + KEY_RIGHTBRACKET = 0x1E, + KEY_BACKSLASH = 0x2A, + KEY_SEMICOLON = 0x29, + KEY_QUOTE = 0x27, + KEY_COMMA = 0x2B, + KEY_PERIOD = 0x2F, + KEY_SLASH = 0x2C, + + // Media keys (on keyboards with them) + KEY_MUTE = 0x4A, + KEY_VOLUMEDOWN = 0x49, + KEY_VOLUMEUP = 0x48, + + // Help/Insert key (not present on most Mac keyboards but exists in code) + KEY_HELP = 0x72, +} + +impl VirtualKeyCode { + /// Convert a raw key code to a VirtualKeyCode enum variant + /// This is useful for debugging key events + #[allow(dead_code)] + fn from_u16(code: u16) -> Option { + // Match against known values + match code { + 0x00 => Some(Self::KEY_A), + 0x01 => Some(Self::KEY_S), + 0x02 => Some(Self::KEY_D), + 0x03 => Some(Self::KEY_F), + 0x04 => Some(Self::KEY_H), + 0x05 => Some(Self::KEY_G), + 0x06 => Some(Self::KEY_Z), + 0x07 => Some(Self::KEY_X), + 0x08 => Some(Self::KEY_C), + 0x09 => Some(Self::KEY_V), + 0x0B => Some(Self::KEY_B), + 0x0C => Some(Self::KEY_Q), + 0x0D => Some(Self::KEY_W), + 0x0E => Some(Self::KEY_E), + 0x0F => Some(Self::KEY_R), + 0x10 => Some(Self::KEY_Y), + 0x11 => Some(Self::KEY_T), + 0x1F => Some(Self::KEY_O), + 0x20 => Some(Self::KEY_U), + 0x22 => Some(Self::KEY_I), + 0x23 => Some(Self::KEY_P), + 0x25 => Some(Self::KEY_L), + 0x26 => Some(Self::KEY_J), + 0x28 => Some(Self::KEY_K), + 0x2D => Some(Self::KEY_N), + 0x2E => Some(Self::KEY_M), + 0x12 => Some(Self::KEY_1), + 0x13 => Some(Self::KEY_2), + 0x14 => Some(Self::KEY_3), + 0x15 => Some(Self::KEY_4), + 0x17 => Some(Self::KEY_5), + 0x16 => Some(Self::KEY_6), + 0x19 => Some(Self::KEY_9), + 0x1A => Some(Self::KEY_7), + 0x1C => Some(Self::KEY_8), + 0x1D => Some(Self::KEY_0), + 0x7A => Some(Self::KEY_F1), + 0x78 => Some(Self::KEY_F2), + 0x63 => Some(Self::KEY_F3), + 0x76 => Some(Self::KEY_F4), + 0x60 => Some(Self::KEY_F5), + 0x61 => Some(Self::KEY_F6), + 0x62 => Some(Self::KEY_F7), + 0x64 => Some(Self::KEY_F8), + 0x65 => Some(Self::KEY_F9), + 0x6D => Some(Self::KEY_F10), + 0x67 => Some(Self::KEY_F11), + 0x6F => Some(Self::KEY_F12), + 0x69 => Some(Self::KEY_F13), + 0x6B => Some(Self::KEY_F14), + 0x71 => Some(Self::KEY_F15), + 0x6A => Some(Self::KEY_F16), + 0x40 => Some(Self::KEY_F17), + 0x4F => Some(Self::KEY_F18), + 0x50 => Some(Self::KEY_F19), + 0x5A => Some(Self::KEY_F20), + 0x39 => Some(Self::KEY_CAPSLOCK), + 0x38 => Some(Self::KEY_SHIFT), + 0x3C => Some(Self::KEY_RIGHTSHIFT), + 0x3B => Some(Self::KEY_CONTROL), + 0x3E => Some(Self::KEY_RIGHTCONTROL), + 0x3A => Some(Self::KEY_OPTION), + 0x3D => Some(Self::KEY_RIGHTOPTION), + 0x37 => Some(Self::KEY_COMMAND), + 0x36 => Some(Self::KEY_RIGHTCOMMAND), + 0x3F => Some(Self::KEY_FN), + 0x24 => Some(Self::KEY_RETURN), + 0x30 => Some(Self::KEY_TAB), + 0x31 => Some(Self::KEY_SPACE), + 0x33 => Some(Self::KEY_DELETE), + 0x35 => Some(Self::KEY_ESCAPE), + 0x75 => Some(Self::KEY_FORWARDDELETE), + 0x73 => Some(Self::KEY_HOME), + 0x77 => Some(Self::KEY_END), + 0x74 => Some(Self::KEY_PAGEUP), + 0x79 => Some(Self::KEY_PAGEDOWN), + 0x7B => Some(Self::KEY_LEFTARROW), + 0x7C => Some(Self::KEY_RIGHTARROW), + 0x7D => Some(Self::KEY_DOWNARROW), + 0x7E => Some(Self::KEY_UPARROW), + 0x32 => Some(Self::KEY_GRAVE), + 0x1B => Some(Self::KEY_MINUS), + 0x18 => Some(Self::KEY_EQUAL), + 0x21 => Some(Self::KEY_LEFTBRACKET), + 0x1E => Some(Self::KEY_RIGHTBRACKET), + 0x2A => Some(Self::KEY_BACKSLASH), + 0x29 => Some(Self::KEY_SEMICOLON), + 0x27 => Some(Self::KEY_QUOTE), + 0x2B => Some(Self::KEY_COMMA), + 0x2F => Some(Self::KEY_PERIOD), + 0x2C => Some(Self::KEY_SLASH), + 0x4A => Some(Self::KEY_MUTE), + 0x49 => Some(Self::KEY_VOLUMEDOWN), + 0x48 => Some(Self::KEY_VOLUMEUP), + 0x72 => Some(Self::KEY_HELP), + _ => None, + } + } +} + +/// macOS-based hotkey listener using CGEventTap +pub struct MacOSListener { + /// The key to listen for + target_key: VirtualKeyCode, + /// Required modifier flags + modifier_flags: CGEventFlags, + /// Optional cancel key + cancel_key: Option, + /// Signal to stop the listener task + stop_signal: Option>, + /// Flag to signal stop from callback + stop_flag: Arc, +} + +impl MacOSListener { + /// Create a new macOS listener for the configured hotkey + pub fn new(config: &HotkeyConfig) -> Result { + let target_key = parse_key_name(&config.key)?; + + let modifier_flags = config + .modifiers + .iter() + .map(|m| parse_modifier_name(m)) + .collect::, _>>()? + .into_iter() + .fold(CGEventFlags::empty(), |acc, flag| acc | flag); + + let cancel_key = config + .cancel_key + .as_ref() + .map(|k| parse_key_name(k)) + .transpose()?; + + // Check for Accessibility permissions + if !check_accessibility_permissions() { + return Err(HotkeyError::DeviceAccess( + "Accessibility permissions required. Please grant access in:\n \ + System Settings > Privacy & Security > Accessibility\n\n \ + Add your terminal application (e.g., Terminal.app, iTerm2, or your IDE) \ + to the list of allowed apps.\n\n \ + After granting access, restart voxtype." + .to_string(), + )); + } + + Ok(Self { + target_key, + modifier_flags, + cancel_key, + stop_signal: None, + stop_flag: Arc::new(AtomicBool::new(false)), + }) + } +} + +/// Check if the application has Accessibility permissions +fn check_accessibility_permissions() -> bool { + // Use the AXIsProcessTrusted function from ApplicationServices framework + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + unsafe { AXIsProcessTrusted() } +} + +#[async_trait::async_trait] +impl HotkeyListener for MacOSListener { + async fn start(&mut self) -> Result, HotkeyError> { + let (tx, rx) = mpsc::channel(32); + let (stop_tx, stop_rx) = oneshot::channel(); + self.stop_signal = Some(stop_tx); + self.stop_flag.store(false, Ordering::SeqCst); + + let target_key = self.target_key; + let modifier_flags = self.modifier_flags; + let cancel_key = self.cancel_key; + let stop_flag = self.stop_flag.clone(); + + // Spawn the listener in a blocking task since CFRunLoop blocks + tokio::task::spawn_blocking(move || { + if let Err(e) = macos_listener_loop( + target_key, + modifier_flags, + cancel_key, + tx, + stop_rx, + stop_flag, + ) { + tracing::error!("macOS hotkey listener error: {}", e); + } + }); + + Ok(rx) + } + + async fn stop(&mut self) -> Result<(), HotkeyError> { + self.stop_flag.store(true, Ordering::SeqCst); + if let Some(stop) = self.stop_signal.take() { + let _ = stop.send(()); + } + // Give the run loop a moment to stop + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tracing::debug!("macOS hotkey listener stopping"); + Ok(()) + } +} + +/// Main listener loop running in a blocking task +fn macos_listener_loop( + target_key: VirtualKeyCode, + modifier_flags: CGEventFlags, + cancel_key: Option, + tx: mpsc::Sender, + _stop_rx: oneshot::Receiver<()>, + stop_flag: Arc, +) -> Result<(), HotkeyError> { + // Track if we're currently "pressed" (to handle repeat events) + let is_pressed = Arc::new(AtomicBool::new(false)); + + // Create a channel for events from the callback + let (event_tx, event_rx) = std_mpsc::channel::(); + + // Clone values for the callback closure + let is_pressed_clone = is_pressed.clone(); + let stop_flag_clone = stop_flag.clone(); + + // Create the event tap callback + let callback = move |_proxy: core_graphics::event::CGEventTapProxy, + event_type: CGEventType, + event: &CGEvent| + -> Option { + // Check stop flag + if stop_flag_clone.load(Ordering::SeqCst) { + CFRunLoop::get_current().stop(); + return Some(event.clone()); + } + + let key_code = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16; + + // Get current modifier flags from the event + let current_flags = event.get_flags(); + + match event_type { + CGEventType::KeyDown => { + // Check cancel key first + if let Some(cancel) = cancel_key { + if key_code == cancel as u16 { + let _ = event_tx.send(HotkeyEvent::Cancel); + return Some(event.clone()); + } + } + + // Check target key with modifiers + if key_code == target_key as u16 { + let modifiers_match = check_modifiers(current_flags, modifier_flags); + + if modifiers_match && !is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("Hotkey pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed); + } + } + } + CGEventType::KeyUp => { + if key_code == target_key as u16 && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("Hotkey released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } + CGEventType::FlagsChanged => { + // Special handling for FN key - detect via flag, not key code + if target_key == VirtualKeyCode::KEY_FN { + let fn_pressed = current_flags.contains(CGEventFlags::CGEventFlagSecondaryFn); + let was_pressed = is_pressed_clone.load(Ordering::SeqCst); + tracing::debug!("FN flag check: fn_pressed={}, was_pressed={}", fn_pressed, was_pressed); + if fn_pressed && !was_pressed { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("FN key pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed); + } else if !fn_pressed && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("FN key released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } else if key_code == target_key as u16 { + // Handle other modifier key changes + let is_modifier_pressed = check_modifier_pressed(key_code, current_flags); + + if is_modifier_pressed && !is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("Modifier hotkey pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed); + } else if !is_modifier_pressed && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("Modifier hotkey released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } + } + _ => {} + } + + // Return the event unchanged (don't consume it) + Some(event.clone()) + }; + + // Create the event tap + let event_tap = CGEventTap::new( + CGEventTapLocation::Session, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + vec![ + CGEventType::KeyDown, + CGEventType::KeyUp, + CGEventType::FlagsChanged, + ], + callback, + ) + .map_err(|_| { + HotkeyError::DeviceAccess( + "Failed to create event tap. Ensure Accessibility permissions are granted.".to_string(), + ) + })?; + + // Enable the event tap + event_tap.enable(); + + // Create a run loop source from the event tap + let run_loop_source = event_tap + .mach_port + .create_runloop_source(0) + .map_err(|_| HotkeyError::DeviceAccess("Failed to create run loop source".to_string()))?; + + // Get the current run loop and add the source + let run_loop = CFRunLoop::get_current(); + run_loop.add_source(&run_loop_source, unsafe { kCFRunLoopCommonModes }); + + if let Some(cancel) = cancel_key { + tracing::info!( + "Listening for {:?} (with modifiers: {:?}) and cancel key {:?} on macOS", + target_key, + modifier_flags, + cancel + ); + } else { + tracing::info!( + "Listening for {:?} (with modifiers: {:?}) on macOS", + target_key, + modifier_flags + ); + } + + // Spawn a thread to forward events from std channel to tokio channel + // and check stop flag periodically + let tx_clone = tx.clone(); + let stop_flag_thread = stop_flag.clone(); + std::thread::spawn(move || { + loop { + if stop_flag_thread.load(Ordering::SeqCst) { + break; + } + + match event_rx.recv_timeout(std::time::Duration::from_millis(100)) { + Ok(event) => { + if tx_clone.blocking_send(event).is_err() { + break; + } + } + Err(std_mpsc::RecvTimeoutError::Timeout) => { + // Continue checking stop flag + } + Err(std_mpsc::RecvTimeoutError::Disconnected) => { + break; + } + } + } + }); + + // Run the event loop (blocks until stopped) + // Run with a timeout to periodically check stop flag + while !stop_flag.load(Ordering::SeqCst) { + CFRunLoop::run_in_mode( + unsafe { kCFRunLoopDefaultMode }, + std::time::Duration::from_millis(100), + true, + ); + } + + tracing::debug!("macOS hotkey listener stopping"); + Ok(()) +} + +/// Check if required modifier flags are satisfied +fn check_modifiers(current: CGEventFlags, required: CGEventFlags) -> bool { + if required.is_empty() { + return true; + } + current.contains(required) +} + +/// Check if a modifier key is currently pressed based on flags +fn check_modifier_pressed(key_code: u16, flags: CGEventFlags) -> bool { + match key_code { + 0x38 | 0x3C => flags.contains(CGEventFlags::CGEventFlagShift), + 0x3B | 0x3E => flags.contains(CGEventFlags::CGEventFlagControl), + 0x3A | 0x3D => flags.contains(CGEventFlags::CGEventFlagAlternate), + 0x37 | 0x36 => flags.contains(CGEventFlags::CGEventFlagCommand), + 0x39 => flags.contains(CGEventFlags::CGEventFlagAlphaShift), + 0x3F => flags.contains(CGEventFlags::CGEventFlagSecondaryFn), + _ => false, + } +} + +/// Parse a key name string to macOS virtual key code +fn parse_key_name(name: &str) -> Result { + // Normalize: uppercase and replace - or space with _ + let normalized: String = name + .chars() + .map(|c| match c { + '-' | ' ' => '_', + c => c.to_ascii_uppercase(), + }) + .collect(); + + // Remove KEY_ prefix if present + let key_name = normalized.strip_prefix("KEY_").unwrap_or(&normalized); + + // Map common key names to macOS virtual key codes + let key = match key_name { + // Lock keys (good hotkey candidates) + "SCROLLLOCK" => { + return Err(HotkeyError::UnknownKey( + "SCROLLLOCK is not available on macOS. Try F13-F20, FN, or a modifier key like RIGHTOPTION".to_string() + )); + } + "PAUSE" => { + return Err(HotkeyError::UnknownKey( + "PAUSE is not available on macOS. Try F13-F20, FN, or a modifier key like RIGHTOPTION".to_string() + )); + } + "CAPSLOCK" => VirtualKeyCode::KEY_CAPSLOCK, + "NUMLOCK" => { + return Err(HotkeyError::UnknownKey( + "NUMLOCK is not available on macOS. Try F13-F20, FN, or CAPSLOCK".to_string(), + )); + } + "INSERT" | "HELP" => VirtualKeyCode::KEY_HELP, + + // Modifier keys + "LEFTALT" | "LALT" | "OPTION" | "LEFTOPTION" => VirtualKeyCode::KEY_OPTION, + "RIGHTALT" | "RALT" | "RIGHTOPTION" | "ALTGR" => VirtualKeyCode::KEY_RIGHTOPTION, + "LEFTCTRL" | "LCTRL" | "CONTROL" | "LEFTCONTROL" => VirtualKeyCode::KEY_CONTROL, + "RIGHTCTRL" | "RCTRL" | "RIGHTCONTROL" => VirtualKeyCode::KEY_RIGHTCONTROL, + "LEFTSHIFT" | "LSHIFT" | "SHIFT" => VirtualKeyCode::KEY_SHIFT, + "RIGHTSHIFT" | "RSHIFT" => VirtualKeyCode::KEY_RIGHTSHIFT, + "LEFTMETA" | "LMETA" | "SUPER" | "COMMAND" | "LEFTCOMMAND" | "CMD" => { + VirtualKeyCode::KEY_COMMAND + } + "RIGHTMETA" | "RMETA" | "RIGHTCOMMAND" | "RCMD" => VirtualKeyCode::KEY_RIGHTCOMMAND, + "FN" | "FUNCTION" | "GLOBE" => VirtualKeyCode::KEY_FN, + + // Function keys (F13-F20 are good hotkey choices on macOS) + "F1" => VirtualKeyCode::KEY_F1, + "F2" => VirtualKeyCode::KEY_F2, + "F3" => VirtualKeyCode::KEY_F3, + "F4" => VirtualKeyCode::KEY_F4, + "F5" => VirtualKeyCode::KEY_F5, + "F6" => VirtualKeyCode::KEY_F6, + "F7" => VirtualKeyCode::KEY_F7, + "F8" => VirtualKeyCode::KEY_F8, + "F9" => VirtualKeyCode::KEY_F9, + "F10" => VirtualKeyCode::KEY_F10, + "F11" => VirtualKeyCode::KEY_F11, + "F12" => VirtualKeyCode::KEY_F12, + "F13" => VirtualKeyCode::KEY_F13, + "F14" => VirtualKeyCode::KEY_F14, + "F15" => VirtualKeyCode::KEY_F15, + "F16" => VirtualKeyCode::KEY_F16, + "F17" => VirtualKeyCode::KEY_F17, + "F18" => VirtualKeyCode::KEY_F18, + "F19" => VirtualKeyCode::KEY_F19, + "F20" => VirtualKeyCode::KEY_F20, + + // Navigation keys + "HOME" => VirtualKeyCode::KEY_HOME, + "END" => VirtualKeyCode::KEY_END, + "PAGEUP" => VirtualKeyCode::KEY_PAGEUP, + "PAGEDOWN" => VirtualKeyCode::KEY_PAGEDOWN, + "DELETE" | "FORWARDDELETE" => VirtualKeyCode::KEY_FORWARDDELETE, + "BACKSPACE" => VirtualKeyCode::KEY_DELETE, + + // Common keys + "SPACE" => VirtualKeyCode::KEY_SPACE, + "ENTER" | "RETURN" => VirtualKeyCode::KEY_RETURN, + "TAB" => VirtualKeyCode::KEY_TAB, + "ESC" | "ESCAPE" => VirtualKeyCode::KEY_ESCAPE, + "GRAVE" | "BACKTICK" => VirtualKeyCode::KEY_GRAVE, + + // Media keys + "MUTE" => VirtualKeyCode::KEY_MUTE, + "VOLUMEDOWN" => VirtualKeyCode::KEY_VOLUMEDOWN, + "VOLUMEUP" => VirtualKeyCode::KEY_VOLUMEUP, + + // If not found, return error with macOS-specific suggestions + _ => { + return Err(HotkeyError::UnknownKey(format!( + "{}. On macOS, try: F13-F20, FN, RIGHTOPTION, or CAPSLOCK. \ + Note: SCROLLLOCK and PAUSE are not available on macOS keyboards.", + name + ))); + } + }; + + Ok(key) +} + +/// Parse a modifier name to CGEventFlags +fn parse_modifier_name(name: &str) -> Result { + let normalized: String = name + .chars() + .map(|c| match c { + '-' | ' ' => '_', + c => c.to_ascii_uppercase(), + }) + .collect(); + + let key_name = normalized.strip_prefix("KEY_").unwrap_or(&normalized); + + match key_name { + "LEFTSHIFT" | "LSHIFT" | "RIGHTSHIFT" | "RSHIFT" | "SHIFT" => { + Ok(CGEventFlags::CGEventFlagShift) + } + "LEFTCTRL" | "LCTRL" | "RIGHTCTRL" | "RCTRL" | "CONTROL" | "CTRL" => { + Ok(CGEventFlags::CGEventFlagControl) + } + "LEFTALT" | "LALT" | "RIGHTALT" | "RALT" | "ALT" | "OPTION" | "LEFTOPTION" + | "RIGHTOPTION" => Ok(CGEventFlags::CGEventFlagAlternate), + "LEFTMETA" | "LMETA" | "RIGHTMETA" | "RMETA" | "SUPER" | "COMMAND" | "CMD" => { + Ok(CGEventFlags::CGEventFlagCommand) + } + "FN" | "FUNCTION" => Ok(CGEventFlags::CGEventFlagSecondaryFn), + _ => Err(HotkeyError::UnknownKey(format!( + "Unknown modifier: {}. On macOS, use: SHIFT, CONTROL, OPTION (ALT), COMMAND, or FN", + name + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_key_name() { + assert_eq!(parse_key_name("F13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!(parse_key_name("f13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!(parse_key_name("KEY_F13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!( + parse_key_name("RIGHTOPTION").unwrap(), + VirtualKeyCode::KEY_RIGHTOPTION + ); + assert_eq!( + parse_key_name("RIGHTALT").unwrap(), + VirtualKeyCode::KEY_RIGHTOPTION + ); + assert_eq!( + parse_key_name("CAPSLOCK").unwrap(), + VirtualKeyCode::KEY_CAPSLOCK + ); + } + + #[test] + fn test_parse_key_name_scrolllock_error() { + let result = parse_key_name("SCROLLLOCK"); + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + HotkeyError::UnknownKey(msg) => { + assert!(msg.contains("not available on macOS")); + } + _ => panic!("Expected UnknownKey error"), + } + } + + #[test] + fn test_parse_modifier_name() { + assert_eq!( + parse_modifier_name("LEFTCTRL").unwrap(), + CGEventFlags::CGEventFlagControl + ); + assert_eq!( + parse_modifier_name("CONTROL").unwrap(), + CGEventFlags::CGEventFlagControl + ); + assert_eq!( + parse_modifier_name("OPTION").unwrap(), + CGEventFlags::CGEventFlagAlternate + ); + assert_eq!( + parse_modifier_name("COMMAND").unwrap(), + CGEventFlags::CGEventFlagCommand + ); + assert_eq!( + parse_modifier_name("SHIFT").unwrap(), + CGEventFlags::CGEventFlagShift + ); + } + + #[test] + fn test_parse_modifier_name_error() { + assert!(parse_modifier_name("INVALID_MOD").is_err()); + } + + #[test] + fn test_check_modifiers_empty() { + let current = CGEventFlags::CGEventFlagShift; + let required = CGEventFlags::empty(); + assert!(check_modifiers(current, required)); + } + + #[test] + fn test_check_modifiers_match() { + let current = CGEventFlags::CGEventFlagShift | CGEventFlags::CGEventFlagControl; + let required = CGEventFlags::CGEventFlagShift; + assert!(check_modifiers(current, required)); + } + + #[test] + fn test_check_modifiers_no_match() { + let current = CGEventFlags::CGEventFlagShift; + let required = CGEventFlags::CGEventFlagControl; + assert!(!check_modifiers(current, required)); + } +} diff --git a/src/hotkey/mod.rs b/src/hotkey/mod.rs index 0420c40..7650f52 100644 --- a/src/hotkey/mod.rs +++ b/src/hotkey/mod.rs @@ -1,13 +1,19 @@ //! Hotkey detection module //! -//! Provides kernel-level key event detection using evdev. -//! This approach works on all Wayland compositors because it -//! operates at the Linux input subsystem level. +//! Provides cross-platform hotkey detection: +//! - Linux: Uses kernel-level evdev interface for key event detection +//! - macOS: Uses CGEventTap for global key event capture, or IOHIDManager for FN/Globe key //! -//! Requires the user to be in the 'input' group. +//! On Linux, the user must be in the 'input' group. +//! On macOS, Accessibility permissions must be granted in System Settings. +//! The FN/Globe key (default on macOS) requires an Apple keyboard. +#[cfg(target_os = "linux")] pub mod evdev_listener; +#[cfg(target_os = "macos")] +pub mod macos; + use crate::config::HotkeyConfig; use crate::error::HotkeyError; use tokio::sync::mpsc; @@ -37,7 +43,8 @@ pub trait HotkeyListener: Send + Sync { async fn stop(&mut self) -> Result<(), HotkeyError>; } -/// Factory function to create the appropriate hotkey listener +/// Factory function to create the appropriate hotkey listener for the current platform +#[cfg(target_os = "linux")] pub fn create_listener( config: &HotkeyConfig, secondary_model: Option, @@ -46,3 +53,51 @@ pub fn create_listener( listener.set_secondary_model(secondary_model); Ok(Box::new(listener)) } + +/// Factory function to create the appropriate hotkey listener for the current platform +#[cfg(target_os = "macos")] +pub fn create_listener( + config: &HotkeyConfig, + _secondary_model: Option, +) -> Result, HotkeyError> { + Ok(Box::new(macos::MacOSListener::new(config)?)) +} + +/// Factory function for unsupported platforms +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub fn create_listener( + _config: &HotkeyConfig, + _secondary_model: Option, +) -> Result, HotkeyError> { + Err(HotkeyError::DeviceAccess( + "Hotkey capture is only supported on Linux and macOS".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hotkey_event_equality() { + assert_eq!(HotkeyEvent::Pressed, HotkeyEvent::Pressed); + assert_eq!(HotkeyEvent::Released, HotkeyEvent::Released); + assert_eq!(HotkeyEvent::Cancel, HotkeyEvent::Cancel); + assert_ne!(HotkeyEvent::Pressed, HotkeyEvent::Released); + assert_ne!(HotkeyEvent::Pressed, HotkeyEvent::Cancel); + } + + #[test] + fn test_hotkey_event_debug() { + let pressed = HotkeyEvent::Pressed; + let debug_str = format!("{:?}", pressed); + assert!(debug_str.contains("Pressed")); + } + + #[test] + fn test_hotkey_event_clone() { + let event = HotkeyEvent::Pressed; + let cloned = event; + assert_eq!(event, cloned); + } +} diff --git a/src/main.rs b/src/main.rs index 8bc595c..2be14ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,7 @@ async fn main() -> anyhow::Result<()> { Some(SetupAction::Check) => { setup::run_checks(&config).await?; } + #[cfg(target_os = "linux")] Some(SetupAction::Systemd { uninstall, status }) => { if status { setup::systemd::status().await?; @@ -193,6 +194,12 @@ async fn main() -> anyhow::Result<()> { setup::systemd::install().await?; } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Systemd { .. }) => { + eprintln!("Error: systemd setup is only available on Linux."); + std::process::exit(1); + } + #[cfg(target_os = "linux")] Some(SetupAction::Waybar { json, css, @@ -211,6 +218,7 @@ async fn main() -> anyhow::Result<()> { setup::waybar::print_config(); } } + #[cfg(target_os = "linux")] Some(SetupAction::Dms { install, uninstall, @@ -226,6 +234,16 @@ async fn main() -> anyhow::Result<()> { setup::dms::print_config(); } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Dms { .. }) => { + eprintln!("Error: DMS setup is only available on Linux."); + std::process::exit(1); + } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Waybar { .. }) => { + eprintln!("Error: Waybar setup is only available on Linux."); + std::process::exit(1); + } Some(SetupAction::Model { list, set, restart }) => { if list { setup::model::list_installed(); @@ -235,6 +253,7 @@ async fn main() -> anyhow::Result<()> { setup::model::interactive_select().await?; } } + #[cfg(target_os = "linux")] Some(SetupAction::Gpu { enable, disable, @@ -251,6 +270,7 @@ async fn main() -> anyhow::Result<()> { setup::gpu::show_status(); } } + #[cfg(target_os = "linux")] Some(SetupAction::Parakeet { enable, disable, status }) => { if status { setup::parakeet::show_status(); @@ -263,9 +283,26 @@ async fn main() -> anyhow::Result<()> { setup::parakeet::show_status(); } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Parakeet { .. }) => { + eprintln!("Error: Parakeet backend management is only available on Linux."); + std::process::exit(1); + } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Gpu { .. }) => { + eprintln!("Error: GPU backend management is only available on Linux."); + eprintln!("On macOS, use --features gpu-metal when building."); + std::process::exit(1); + } + #[cfg(target_os = "linux")] Some(SetupAction::Compositor { compositor_type }) => { setup::compositor::run(&compositor_type).await?; } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Compositor { .. }) => { + eprintln!("Error: Compositor setup is only available on Linux."); + std::process::exit(1); + } None => { // Default: run setup (non-blocking) setup::run_setup(&config, download, model.as_deref(), quiet, no_post_install) @@ -296,6 +333,7 @@ async fn main() -> anyhow::Result<()> { } /// Send a record command to the running daemon via Unix signals or file triggers +#[cfg(target_os = "linux")] fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow::Result<()> { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; @@ -426,6 +464,110 @@ fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow: Ok(()) } +/// Send a record command to the running daemon via Unix signals or file triggers (macOS version) +#[cfg(target_os = "macos")] +fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow::Result<()> { + use voxtype::OutputModeOverride; + + // Read PID from the pid file + let pid_file = config::Config::runtime_dir().join("pid"); + + if !pid_file.exists() { + eprintln!("Error: Voxtype daemon is not running."); + eprintln!("Start it with: voxtype daemon"); + std::process::exit(1); + } + + let pid_str = std::fs::read_to_string(&pid_file) + .map_err(|e| anyhow::anyhow!("Failed to read PID file: {}", e))?; + + let pid: i32 = pid_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid PID in file: {}", e))?; + + // Check if the process is actually running using libc::kill with signal 0 + let process_exists = unsafe { libc::kill(pid, 0) } == 0; + if !process_exists { + // Process doesn't exist, clean up stale PID file + let _ = std::fs::remove_file(&pid_file); + eprintln!("Error: Voxtype daemon is not running (stale PID file removed)."); + eprintln!("Start it with: voxtype daemon"); + std::process::exit(1); + } + + // Handle cancel separately (uses file trigger instead of signal) + if matches!(action, RecordAction::Cancel) { + let cancel_file = config::Config::runtime_dir().join("cancel"); + std::fs::write(&cancel_file, "cancel") + .map_err(|e| anyhow::anyhow!("Failed to write cancel file: {}", e))?; + return Ok(()); + } + + // Write output mode override file if specified + // For file mode, format is "file" or "file:/path/to/file" + if let Some(mode_override) = action.output_mode_override() { + let override_file = config::Config::runtime_dir().join("output_mode_override"); + let mode_str = match mode_override { + OutputModeOverride::Type => "type".to_string(), + OutputModeOverride::Clipboard => "clipboard".to_string(), + OutputModeOverride::Paste => "paste".to_string(), + OutputModeOverride::File => { + // Check if explicit path was provided with --file=path + match action.file_path() { + Some(path) if !path.is_empty() => format!("file:{}", path), + _ => "file".to_string(), + } + } + }; + std::fs::write(&override_file, mode_str) + .map_err(|e| anyhow::anyhow!("Failed to write output mode override: {}", e))?; + } + + // For toggle, we need to read current state to decide which signal to send + let signal = match &action { + RecordAction::Start { .. } => libc::SIGUSR1, + RecordAction::Stop { .. } => libc::SIGUSR2, + RecordAction::Toggle { .. } => { + // Read current state to determine action + let state_file = match config.resolve_state_file() { + Some(path) => path, + None => { + eprintln!("Error: Cannot toggle recording without state_file configured."); + eprintln!(); + eprintln!("Add to your config.toml:"); + eprintln!(" state_file = \"auto\""); + eprintln!(); + eprintln!("Or use explicit start/stop commands:"); + eprintln!(" voxtype record start"); + eprintln!(" voxtype record stop"); + std::process::exit(1); + } + }; + + let current_state = + std::fs::read_to_string(&state_file).unwrap_or_else(|_| "idle".to_string()); + + if current_state.trim() == "recording" { + libc::SIGUSR2 // Stop + } else { + libc::SIGUSR1 // Start + } + } + RecordAction::Cancel => unreachable!(), // Handled above + }; + + let result = unsafe { libc::kill(pid, signal) }; + if result != 0 { + return Err(anyhow::anyhow!( + "Failed to send signal to daemon: {}", + std::io::Error::last_os_error() + )); + } + + Ok(()) +} + /// Transcribe an audio file fn transcribe_file(config: &config::Config, path: &PathBuf) -> anyhow::Result<()> { use hound::WavReader; @@ -523,6 +665,7 @@ struct ExtendedStatusInfo { } impl ExtendedStatusInfo { + #[cfg(target_os = "linux")] fn from_config(config: &config::Config) -> Self { let backend = setup::gpu::detect_current_backend() .map(|b| match b { @@ -540,9 +683,25 @@ impl ExtendedStatusInfo { backend, } } + + #[cfg(target_os = "macos")] + fn from_config(config: &config::Config) -> Self { + // On macOS, determine backend from build features + #[cfg(feature = "gpu-metal")] + let backend = "GPU (Metal)".to_string(); + #[cfg(not(feature = "gpu-metal"))] + let backend = "CPU".to_string(); + + Self { + model: config.whisper.model.clone(), + device: config.audio.device.clone(), + backend, + } + } } -/// Check if the daemon is actually running by verifying the PID file +/// Check if the daemon is actually running by verifying the PID file (Linux version) +#[cfg(target_os = "linux")] fn is_daemon_running() -> bool { let pid_path = config::Config::runtime_dir().join("pid"); @@ -561,6 +720,26 @@ fn is_daemon_running() -> bool { std::path::Path::new(&format!("/proc/{}", pid)).exists() } +/// Check if the daemon is actually running by verifying the PID file (macOS version) +#[cfg(target_os = "macos")] +fn is_daemon_running() -> bool { + let pid_path = config::Config::runtime_dir().join("pid"); + + // Read PID from file + let pid_str = match std::fs::read_to_string(&pid_path) { + Ok(s) => s, + Err(_) => return false, // No PID file = not running + }; + + let pid: i32 = match pid_str.trim().parse() { + Ok(p) => p, + Err(_) => return false, // Invalid PID = not running + }; + + // Check if process exists using kill with signal 0 + unsafe { libc::kill(pid, 0) == 0 } +} + /// Run the status command - show current daemon state async fn run_status( config: &config::Config, @@ -616,7 +795,7 @@ async fn run_status( return Ok(()); } - // Follow mode: watch for changes using inotify + // Follow mode: watch for changes using notify crate use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; use std::sync::mpsc::channel; use std::time::Duration; @@ -863,9 +1042,19 @@ async fn show_config(config: &config::Config) -> anyhow::Result<()> { } } - // Show output chain status - let output_status = setup::detect_output_chain().await; - setup::print_output_chain_status(&output_status); + // Show output chain status (Linux only) + #[cfg(target_os = "linux")] + { + let output_status = setup::detect_output_chain().await; + setup::print_output_chain_status(&output_status); + } + + #[cfg(target_os = "macos")] + { + println!("\nOutput Chain:"); + println!(" Platform: macOS"); + println!(" Note: Text output via external tools not yet implemented on macOS"); + } println!("\n---"); println!( diff --git a/src/output/cgevent.rs b/src/output/cgevent.rs new file mode 100644 index 0000000..186f284 --- /dev/null +++ b/src/output/cgevent.rs @@ -0,0 +1,546 @@ +//! CGEvent-based text output for macOS +//! +//! Uses the macOS CGEvent API to simulate keyboard input. This is the native +//! method for text injection on macOS. +//! +//! Requires: +//! - macOS 10.15 or later +//! - Accessibility permissions (System Preferences > Security & Privacy > Accessibility) +//! +//! The implementation supports: +//! - Standard US keyboard layout character-to-keycode mapping +//! - Unicode character support via CGEventKeyboardSetUnicodeString +//! - Configurable typing delays +//! - Auto-submit (Enter key) after output + +use super::TextOutput; +use crate::error::OutputError; +use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGKeyCode}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use std::time::Duration; + +/// CGEvent-based text output for macOS +pub struct CGEventOutput { + /// Delay between keypresses in milliseconds + type_delay_ms: u32, + /// Delay before typing starts in milliseconds + pre_type_delay_ms: u32, + /// Whether to show a desktop notification + notify: bool, + /// Whether to send Enter key after output + auto_submit: bool, +} + +impl CGEventOutput { + /// Create a new CGEvent output + pub fn new( + type_delay_ms: u32, + pre_type_delay_ms: u32, + notify: bool, + auto_submit: bool, + ) -> Self { + Self { + type_delay_ms, + pre_type_delay_ms, + notify, + auto_submit, + } + } + + /// Check if we have Accessibility permissions + /// + /// On macOS, simulating keyboard events requires the app to be granted + /// Accessibility permissions in System Preferences. + fn check_accessibility_permission() -> bool { + // Link against ApplicationServices framework for AXIsProcessTrusted + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + unsafe { AXIsProcessTrusted() } + } + + /// Prompt the user to grant Accessibility permissions + /// + /// Logs instructions for granting permissions. + fn prompt_accessibility_permission() { + tracing::warn!( + "Accessibility permission required. Please grant permission in:\n\ + System Preferences > Security & Privacy > Privacy > Accessibility\n\ + Then restart the application." + ); + } + + /// Send a desktop notification using osascript + async fn send_notification(&self, text: &str) { + use std::process::Stdio; + use tokio::process::Command; + + // Truncate preview for notification + let preview: String = text.chars().take(100).collect(); + let preview = if text.chars().count() > 100 { + format!("{}...", preview) + } else { + preview + }; + + // Escape quotes for AppleScript + let escaped = preview.replace('\\', "\\\\").replace('"', "\\\""); + + let script = format!( + "display notification \"{}\" with title \"Voxtype\" subtitle \"Transcribed\"", + escaped + ); + + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } + + /// Type a single character using CGEvent + /// + /// For ASCII characters, uses keycode mapping with proper modifiers. + /// For Unicode characters, uses CGEventKeyboardSetUnicodeString. + fn type_character(source: &CGEventSource, ch: char) -> Result<(), OutputError> { + // Try to map to a keycode first (faster and more reliable for ASCII) + if let Some((keycode, shift_needed)) = char_to_keycode(ch) { + Self::press_key(source, keycode, shift_needed)?; + } else { + // Fall back to Unicode string injection for non-ASCII characters + Self::type_unicode_char(source, ch)?; + } + Ok(()) + } + + /// Press a key with optional shift modifier + fn press_key( + source: &CGEventSource, + keycode: CGKeyCode, + shift_needed: bool, + ) -> Result<(), OutputError> { + // Create key down event + let key_down = CGEvent::new_keyboard_event(source.clone(), keycode, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create key down event".into()))?; + + // Create key up event + let key_up = CGEvent::new_keyboard_event(source.clone(), keycode, false) + .map_err(|_| OutputError::InjectionFailed("Failed to create key up event".into()))?; + + // Set shift modifier if needed + if shift_needed { + key_down.set_flags(CGEventFlags::CGEventFlagShift); + key_up.set_flags(CGEventFlags::CGEventFlagShift); + } + + // Post the events + key_down.post(CGEventTapLocation::HID); + key_up.post(CGEventTapLocation::HID); + + Ok(()) + } + + /// Type a Unicode character using CGEventKeyboardSetUnicodeString + fn type_unicode_char(source: &CGEventSource, ch: char) -> Result<(), OutputError> { + // Create a keyboard event (keycode 0 is placeholder, we override with Unicode) + let event = CGEvent::new_keyboard_event(source.clone(), 0, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create Unicode event".into()))?; + + // Convert char to UTF-16 + let mut utf16_buf = [0u16; 2]; + let utf16 = ch.encode_utf16(&mut utf16_buf); + + // Set the Unicode string on the event + event.set_string_from_utf16_unchecked(utf16); + + // Post key down + event.post(CGEventTapLocation::HID); + + // Create and post key up + let event_up = CGEvent::new_keyboard_event(source.clone(), 0, false).map_err(|_| { + OutputError::InjectionFailed("Failed to create Unicode up event".into()) + })?; + event_up.set_string_from_utf16_unchecked(utf16); + event_up.post(CGEventTapLocation::HID); + + Ok(()) + } + + /// Press the Enter/Return key + fn press_enter(source: &CGEventSource) -> Result<(), OutputError> { + Self::press_key(source, KEYCODE_RETURN, false) + } + + /// Type text in a blocking manner (for use in spawn_blocking) + /// + /// This function handles all CGEvent operations synchronously, + /// including inter-keystroke delays using std::thread::sleep. + fn type_text_blocking( + text: &str, + type_delay_ms: u32, + auto_submit: bool, + ) -> Result<(), OutputError> { + // Create event source + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|_| OutputError::InjectionFailed("Failed to create CGEventSource".into()))?; + + // Type each character + let delay = Duration::from_millis(type_delay_ms as u64); + for ch in text.chars() { + Self::type_character(&source, ch)?; + + // Add delay between keystrokes if configured + if type_delay_ms > 0 { + std::thread::sleep(delay); + } + } + + // Send Enter key if configured + if auto_submit { + // Small delay before Enter to ensure all text is processed + std::thread::sleep(Duration::from_millis(50)); + Self::press_enter(&source)?; + } + + Ok(()) + } +} + +#[async_trait::async_trait] +impl TextOutput for CGEventOutput { + async fn output(&self, text: &str) -> Result<(), OutputError> { + if text.is_empty() { + return Ok(()); + } + + // Check accessibility permissions first (can be done on main thread) + if !Self::check_accessibility_permission() { + Self::prompt_accessibility_permission(); + return Err(OutputError::InjectionFailed( + "Accessibility permission denied. Grant permission in System Preferences > \ + Security & Privacy > Privacy > Accessibility, then restart the application." + .into(), + )); + } + + // Pre-typing delay if configured + if self.pre_type_delay_ms > 0 { + tracing::debug!( + "cgevent: sleeping {}ms before typing", + self.pre_type_delay_ms + ); + tokio::time::sleep(Duration::from_millis(self.pre_type_delay_ms as u64)).await; + } + + tracing::debug!( + "cgevent: typing text: \"{}\"", + text.chars().take(20).collect::() + ); + + // CGEventSource is not Send, so we need to do all CGEvent work in a blocking task + let text_owned = text.to_string(); + let type_delay_ms = self.type_delay_ms; + let auto_submit = self.auto_submit; + + let result = tokio::task::spawn_blocking(move || { + Self::type_text_blocking(&text_owned, type_delay_ms, auto_submit) + }) + .await + .map_err(|e| OutputError::InjectionFailed(format!("Task join error: {}", e)))??; + + tracing::info!("Text typed via CGEvent ({} chars)", text.len()); + + // Send notification if enabled + if self.notify { + self.send_notification(text).await; + } + + Ok(result) + } + + async fn is_available(&self) -> bool { + // CGEvent is always available on macOS, but we check for accessibility permissions + // We return true here so the output method can provide a helpful error message + // if permissions are denied + true + } + + fn name(&self) -> &'static str { + "cgevent" + } +} + +// macOS virtual key codes for US keyboard layout +// Reference: /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Headers/Events.h + +const KEYCODE_A: CGKeyCode = 0x00; +const KEYCODE_S: CGKeyCode = 0x01; +const KEYCODE_D: CGKeyCode = 0x02; +const KEYCODE_F: CGKeyCode = 0x03; +const KEYCODE_H: CGKeyCode = 0x04; +const KEYCODE_G: CGKeyCode = 0x05; +const KEYCODE_Z: CGKeyCode = 0x06; +const KEYCODE_X: CGKeyCode = 0x07; +const KEYCODE_C: CGKeyCode = 0x08; +const KEYCODE_V: CGKeyCode = 0x09; +const KEYCODE_B: CGKeyCode = 0x0B; +const KEYCODE_Q: CGKeyCode = 0x0C; +const KEYCODE_W: CGKeyCode = 0x0D; +const KEYCODE_E: CGKeyCode = 0x0E; +const KEYCODE_R: CGKeyCode = 0x0F; +const KEYCODE_Y: CGKeyCode = 0x10; +const KEYCODE_T: CGKeyCode = 0x11; +const KEYCODE_1: CGKeyCode = 0x12; +const KEYCODE_2: CGKeyCode = 0x13; +const KEYCODE_3: CGKeyCode = 0x14; +const KEYCODE_4: CGKeyCode = 0x15; +const KEYCODE_6: CGKeyCode = 0x16; +const KEYCODE_5: CGKeyCode = 0x17; +const KEYCODE_EQUAL: CGKeyCode = 0x18; +const KEYCODE_9: CGKeyCode = 0x19; +const KEYCODE_7: CGKeyCode = 0x1A; +const KEYCODE_MINUS: CGKeyCode = 0x1B; +const KEYCODE_8: CGKeyCode = 0x1C; +const KEYCODE_0: CGKeyCode = 0x1D; +const KEYCODE_RIGHT_BRACKET: CGKeyCode = 0x1E; +const KEYCODE_O: CGKeyCode = 0x1F; +const KEYCODE_U: CGKeyCode = 0x20; +const KEYCODE_LEFT_BRACKET: CGKeyCode = 0x21; +const KEYCODE_I: CGKeyCode = 0x22; +const KEYCODE_P: CGKeyCode = 0x23; +const KEYCODE_RETURN: CGKeyCode = 0x24; +const KEYCODE_L: CGKeyCode = 0x25; +const KEYCODE_J: CGKeyCode = 0x26; +const KEYCODE_QUOTE: CGKeyCode = 0x27; +const KEYCODE_K: CGKeyCode = 0x28; +const KEYCODE_SEMICOLON: CGKeyCode = 0x29; +const KEYCODE_BACKSLASH: CGKeyCode = 0x2A; +const KEYCODE_COMMA: CGKeyCode = 0x2B; +const KEYCODE_SLASH: CGKeyCode = 0x2C; +const KEYCODE_N: CGKeyCode = 0x2D; +const KEYCODE_M: CGKeyCode = 0x2E; +const KEYCODE_PERIOD: CGKeyCode = 0x2F; +const KEYCODE_TAB: CGKeyCode = 0x30; +const KEYCODE_SPACE: CGKeyCode = 0x31; +const KEYCODE_GRAVE: CGKeyCode = 0x32; + +/// Map a character to a macOS virtual keycode and whether shift is needed +/// +/// Returns Some((keycode, shift_needed)) for ASCII characters that can be +/// typed with the US keyboard layout, None for characters that need Unicode input. +pub fn char_to_keycode(ch: char) -> Option<(CGKeyCode, bool)> { + match ch { + // Lowercase letters (no shift) + 'a' => Some((KEYCODE_A, false)), + 'b' => Some((KEYCODE_B, false)), + 'c' => Some((KEYCODE_C, false)), + 'd' => Some((KEYCODE_D, false)), + 'e' => Some((KEYCODE_E, false)), + 'f' => Some((KEYCODE_F, false)), + 'g' => Some((KEYCODE_G, false)), + 'h' => Some((KEYCODE_H, false)), + 'i' => Some((KEYCODE_I, false)), + 'j' => Some((KEYCODE_J, false)), + 'k' => Some((KEYCODE_K, false)), + 'l' => Some((KEYCODE_L, false)), + 'm' => Some((KEYCODE_M, false)), + 'n' => Some((KEYCODE_N, false)), + 'o' => Some((KEYCODE_O, false)), + 'p' => Some((KEYCODE_P, false)), + 'q' => Some((KEYCODE_Q, false)), + 'r' => Some((KEYCODE_R, false)), + 's' => Some((KEYCODE_S, false)), + 't' => Some((KEYCODE_T, false)), + 'u' => Some((KEYCODE_U, false)), + 'v' => Some((KEYCODE_V, false)), + 'w' => Some((KEYCODE_W, false)), + 'x' => Some((KEYCODE_X, false)), + 'y' => Some((KEYCODE_Y, false)), + 'z' => Some((KEYCODE_Z, false)), + + // Uppercase letters (shift) + 'A' => Some((KEYCODE_A, true)), + 'B' => Some((KEYCODE_B, true)), + 'C' => Some((KEYCODE_C, true)), + 'D' => Some((KEYCODE_D, true)), + 'E' => Some((KEYCODE_E, true)), + 'F' => Some((KEYCODE_F, true)), + 'G' => Some((KEYCODE_G, true)), + 'H' => Some((KEYCODE_H, true)), + 'I' => Some((KEYCODE_I, true)), + 'J' => Some((KEYCODE_J, true)), + 'K' => Some((KEYCODE_K, true)), + 'L' => Some((KEYCODE_L, true)), + 'M' => Some((KEYCODE_M, true)), + 'N' => Some((KEYCODE_N, true)), + 'O' => Some((KEYCODE_O, true)), + 'P' => Some((KEYCODE_P, true)), + 'Q' => Some((KEYCODE_Q, true)), + 'R' => Some((KEYCODE_R, true)), + 'S' => Some((KEYCODE_S, true)), + 'T' => Some((KEYCODE_T, true)), + 'U' => Some((KEYCODE_U, true)), + 'V' => Some((KEYCODE_V, true)), + 'W' => Some((KEYCODE_W, true)), + 'X' => Some((KEYCODE_X, true)), + 'Y' => Some((KEYCODE_Y, true)), + 'Z' => Some((KEYCODE_Z, true)), + + // Numbers (no shift) + '0' => Some((KEYCODE_0, false)), + '1' => Some((KEYCODE_1, false)), + '2' => Some((KEYCODE_2, false)), + '3' => Some((KEYCODE_3, false)), + '4' => Some((KEYCODE_4, false)), + '5' => Some((KEYCODE_5, false)), + '6' => Some((KEYCODE_6, false)), + '7' => Some((KEYCODE_7, false)), + '8' => Some((KEYCODE_8, false)), + '9' => Some((KEYCODE_9, false)), + + // Shifted number row symbols + '!' => Some((KEYCODE_1, true)), + '@' => Some((KEYCODE_2, true)), + '#' => Some((KEYCODE_3, true)), + '$' => Some((KEYCODE_4, true)), + '%' => Some((KEYCODE_5, true)), + '^' => Some((KEYCODE_6, true)), + '&' => Some((KEYCODE_7, true)), + '*' => Some((KEYCODE_8, true)), + '(' => Some((KEYCODE_9, true)), + ')' => Some((KEYCODE_0, true)), + + // Punctuation (no shift) + '-' => Some((KEYCODE_MINUS, false)), + '=' => Some((KEYCODE_EQUAL, false)), + '[' => Some((KEYCODE_LEFT_BRACKET, false)), + ']' => Some((KEYCODE_RIGHT_BRACKET, false)), + '\\' => Some((KEYCODE_BACKSLASH, false)), + ';' => Some((KEYCODE_SEMICOLON, false)), + '\'' => Some((KEYCODE_QUOTE, false)), + ',' => Some((KEYCODE_COMMA, false)), + '.' => Some((KEYCODE_PERIOD, false)), + '/' => Some((KEYCODE_SLASH, false)), + '`' => Some((KEYCODE_GRAVE, false)), + + // Punctuation (shift) + '_' => Some((KEYCODE_MINUS, true)), + '+' => Some((KEYCODE_EQUAL, true)), + '{' => Some((KEYCODE_LEFT_BRACKET, true)), + '}' => Some((KEYCODE_RIGHT_BRACKET, true)), + '|' => Some((KEYCODE_BACKSLASH, true)), + ':' => Some((KEYCODE_SEMICOLON, true)), + '"' => Some((KEYCODE_QUOTE, true)), + '<' => Some((KEYCODE_COMMA, true)), + '>' => Some((KEYCODE_PERIOD, true)), + '?' => Some((KEYCODE_SLASH, true)), + '~' => Some((KEYCODE_GRAVE, true)), + + // Whitespace + ' ' => Some((KEYCODE_SPACE, false)), + '\t' => Some((KEYCODE_TAB, false)), + '\n' => Some((KEYCODE_RETURN, false)), + + // All other characters need Unicode input + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let output = CGEventOutput::new(10, 0, true, false); + assert_eq!(output.type_delay_ms, 10); + assert_eq!(output.pre_type_delay_ms, 0); + assert!(output.notify); + assert!(!output.auto_submit); + } + + #[test] + fn test_new_with_auto_submit() { + let output = CGEventOutput::new(0, 0, false, true); + assert_eq!(output.type_delay_ms, 0); + assert!(!output.notify); + assert!(output.auto_submit); + } + + #[test] + fn test_new_with_pre_type_delay() { + let output = CGEventOutput::new(0, 200, false, false); + assert_eq!(output.type_delay_ms, 0); + assert_eq!(output.pre_type_delay_ms, 200); + } + + #[test] + fn test_char_to_keycode_lowercase() { + assert_eq!(char_to_keycode('a'), Some((KEYCODE_A, false))); + assert_eq!(char_to_keycode('z'), Some((KEYCODE_Z, false))); + assert_eq!(char_to_keycode('m'), Some((KEYCODE_M, false))); + } + + #[test] + fn test_char_to_keycode_uppercase() { + assert_eq!(char_to_keycode('A'), Some((KEYCODE_A, true))); + assert_eq!(char_to_keycode('Z'), Some((KEYCODE_Z, true))); + assert_eq!(char_to_keycode('M'), Some((KEYCODE_M, true))); + } + + #[test] + fn test_char_to_keycode_numbers() { + assert_eq!(char_to_keycode('0'), Some((KEYCODE_0, false))); + assert_eq!(char_to_keycode('1'), Some((KEYCODE_1, false))); + assert_eq!(char_to_keycode('9'), Some((KEYCODE_9, false))); + } + + #[test] + fn test_char_to_keycode_shifted_numbers() { + assert_eq!(char_to_keycode('!'), Some((KEYCODE_1, true))); + assert_eq!(char_to_keycode('@'), Some((KEYCODE_2, true))); + assert_eq!(char_to_keycode('#'), Some((KEYCODE_3, true))); + assert_eq!(char_to_keycode('$'), Some((KEYCODE_4, true))); + assert_eq!(char_to_keycode('%'), Some((KEYCODE_5, true))); + assert_eq!(char_to_keycode('^'), Some((KEYCODE_6, true))); + assert_eq!(char_to_keycode('&'), Some((KEYCODE_7, true))); + assert_eq!(char_to_keycode('*'), Some((KEYCODE_8, true))); + assert_eq!(char_to_keycode('('), Some((KEYCODE_9, true))); + assert_eq!(char_to_keycode(')'), Some((KEYCODE_0, true))); + } + + #[test] + fn test_char_to_keycode_punctuation() { + assert_eq!(char_to_keycode('.'), Some((KEYCODE_PERIOD, false))); + assert_eq!(char_to_keycode(','), Some((KEYCODE_COMMA, false))); + assert_eq!(char_to_keycode(';'), Some((KEYCODE_SEMICOLON, false))); + assert_eq!(char_to_keycode(':'), Some((KEYCODE_SEMICOLON, true))); + assert_eq!(char_to_keycode('\''), Some((KEYCODE_QUOTE, false))); + assert_eq!(char_to_keycode('"'), Some((KEYCODE_QUOTE, true))); + } + + #[test] + fn test_char_to_keycode_whitespace() { + assert_eq!(char_to_keycode(' '), Some((KEYCODE_SPACE, false))); + assert_eq!(char_to_keycode('\t'), Some((KEYCODE_TAB, false))); + assert_eq!(char_to_keycode('\n'), Some((KEYCODE_RETURN, false))); + } + + #[test] + fn test_char_to_keycode_unicode() { + // Unicode characters should return None (need Unicode input) + assert_eq!(char_to_keycode('\u{00E9}'), None); // e with acute accent + assert_eq!(char_to_keycode('\u{00F1}'), None); // n with tilde + assert_eq!(char_to_keycode('\u{4E2D}'), None); // Chinese character + } + + #[test] + fn test_char_to_keycode_brackets() { + assert_eq!(char_to_keycode('['), Some((KEYCODE_LEFT_BRACKET, false))); + assert_eq!(char_to_keycode(']'), Some((KEYCODE_RIGHT_BRACKET, false))); + assert_eq!(char_to_keycode('{'), Some((KEYCODE_LEFT_BRACKET, true))); + assert_eq!(char_to_keycode('}'), Some((KEYCODE_RIGHT_BRACKET, true))); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index e1a4402..f815119 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -2,14 +2,18 @@ //! //! Provides text output via keyboard simulation or clipboard. //! -//! Fallback chain for `mode = "type"`: +//! Fallback chain for `mode = "type"` on Linux: //! 1. wtype - Wayland-native, best Unicode/CJK support, no daemon needed //! 2. dotool - Works on X11/Wayland/TTY, supports keyboard layouts, no daemon needed //! 3. ydotool - Works on X11/Wayland/TTY, requires daemon //! 4. clipboard (wl-copy) - Wayland clipboard fallback //! 5. xclip - X11 clipboard fallback //! -//! Paste mode (clipboard + Ctrl+V) helps with system with non US keyboard layouts. +//! On macOS: +//! 1. cgevent - Native CGEvent API for keyboard simulation +//! 2. clipboard - Fallback (requires pbcopy/pbpaste) +//! +//! Paste mode (clipboard + Ctrl+V) helps with systems with non US keyboard layouts. pub mod clipboard; pub mod dotool; @@ -19,6 +23,9 @@ pub mod wtype; pub mod xclip; pub mod ydotool; +#[cfg(target_os = "macos")] +pub mod cgevent; + use crate::config::{OutputConfig, OutputDriver}; use crate::error::OutputError; use std::borrow::Cow; @@ -201,45 +208,66 @@ pub fn create_output_chain_with_override( match config.mode { crate::config::OutputMode::Type => { - // Determine driver order: CLI override > config > default - let driver_order: &[OutputDriver] = driver_override - .or(config.driver_order.as_deref()) - .unwrap_or(DEFAULT_DRIVER_ORDER); - - if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { - tracing::info!( - "Using custom driver order: {}", - custom_order - .iter() - .map(|d| d.to_string()) - .collect::>() - .join(" -> ") - ); - } + // Platform-specific output methods + #[cfg(target_os = "macos")] + { + // macOS: CGEvent is the primary (and only) method + chain.push(Box::new(cgevent::CGEventOutput::new( + config.type_delay_ms, + pre_type_delay_ms, + config.notification.on_transcription, + config.auto_submit, + ))); - // Build chain based on driver order - for (i, driver) in driver_order.iter().enumerate() { - // Skip clipboard if it's in the middle and fallback_to_clipboard is false - // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) - let is_last = i == driver_order.len() - 1; - if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard { - continue; + // Clipboard fallback on macOS + if config.fallback_to_clipboard { + chain.push(Box::new(clipboard::ClipboardOutput::new(false))); } - - chain.push(create_driver_output( - *driver, - config, - pre_type_delay_ms, - i == 0, - )); } - // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it - if config.fallback_to_clipboard - && config.driver_order.is_some() - && !driver_order.contains(&OutputDriver::Clipboard) + #[cfg(not(target_os = "macos"))] { - chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + // Determine driver order: CLI override > config > default + let driver_order: &[OutputDriver] = driver_override + .or(config.driver_order.as_deref()) + .unwrap_or(DEFAULT_DRIVER_ORDER); + + if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { + tracing::info!( + "Using custom driver order: {}", + custom_order + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(" -> ") + ); + } + + // Build chain based on driver order + for (i, driver) in driver_order.iter().enumerate() { + // Skip clipboard if it's in the middle and fallback_to_clipboard is false + // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) + let is_last = i == driver_order.len() - 1; + if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard + { + continue; + } + + chain.push(create_driver_output( + *driver, + config, + pre_type_delay_ms, + i == 0, + )); + } + + // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it + if config.fallback_to_clipboard + && config.driver_order.is_some() + && !driver_order.contains(&OutputDriver::Clipboard) + { + chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + } } } crate::config::OutputMode::Clipboard => { diff --git a/src/output/post_process.rs b/src/output/post_process.rs index 195d57e..6dbd853 100644 --- a/src/output/post_process.rs +++ b/src/output/post_process.rs @@ -198,8 +198,8 @@ mod tests { #[tokio::test] async fn test_empty_output_fallback() { - // echo -n outputs nothing, which should trigger fallback - let config = make_config("echo -n ''", 5000); + // printf with empty string outputs nothing, which should trigger fallback + let config = make_config("printf ''", 5000); let processor = PostProcessor::new(&config); let result = processor.process("original text").await; assert_eq!(result, "original text"); // Falls back to original diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 4c21c04..0f5d945 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,20 +1,27 @@ //! Setup module for voxtype installation and configuration //! //! Provides subcommands for: -//! - systemd service installation -//! - Waybar configuration generation +//! - systemd service installation (Linux only) +//! - Waybar configuration generation (Linux only) +//! - DMS configuration (Linux only) //! - Interactive model selection //! - Output chain detection -//! - GPU backend management -//! - Parakeet backend management -//! - Compositor integration (modifier key fix) +//! - GPU backend management (Linux only) +//! - Parakeet backend management (Linux only) +//! - Compositor integration (modifier key fix, Linux only) +#[cfg(target_os = "linux")] pub mod compositor; +#[cfg(target_os = "linux")] pub mod dms; +#[cfg(target_os = "linux")] pub mod gpu; pub mod model; +#[cfg(target_os = "linux")] pub mod parakeet; +#[cfg(target_os = "linux")] pub mod systemd; +#[cfg(target_os = "linux")] pub mod waybar; use crate::config::Config; @@ -61,7 +68,8 @@ pub struct OutputChainStatus { pub primary_method: Option, } -/// Check if user is in a specific group +/// Check if user is in a specific group (Unix-specific) +#[cfg(unix)] pub fn user_in_group(group: &str) -> bool { std::process::Command::new("groups") .output() @@ -649,37 +657,50 @@ pub async fn run_checks(config: &Config) -> anyhow::Result<()> { } } - // Check input group - println!("\nInput:"); - if user_in_group("input") { - print_success("User is in 'input' group (evdev hotkeys available)"); - } else { - print_warning("User is not in 'input' group (evdev hotkeys unavailable)"); - println!(" Required only for evdev hotkey mode, not compositor keybindings"); - println!(" To enable: sudo usermod -aG input $USER && logout"); + // Check input group (Linux-specific evdev support) + #[cfg(target_os = "linux")] + { + println!("\nInput:"); + if user_in_group("input") { + print_success("User is in 'input' group (evdev hotkeys available)"); + } else { + print_warning("User is not in 'input' group (evdev hotkeys unavailable)"); + println!(" Required only for evdev hotkey mode, not compositor keybindings"); + println!(" To enable: sudo usermod -aG input $USER && logout"); + } } - // Check output chain - let output_status = detect_output_chain().await; - print_output_chain_status(&output_status); + // Check output chain (Linux tools) + #[cfg(target_os = "linux")] + { + let output_status = detect_output_chain().await; + print_output_chain_status(&output_status); - if output_status.primary_method.is_none() { - print_failure("No text output method available"); - if output_status.display_server == DisplayServer::Wayland { - println!(" Install wtype: sudo pacman -S wtype"); - } else { - println!(" Install ydotool: sudo pacman -S ydotool"); - } - all_ok = false; - } else if output_status.primary_method.as_deref() == Some("clipboard") { - print_warning("Only clipboard mode available - typing won't work"); - if output_status.display_server == DisplayServer::Wayland { - println!(" Install wtype: sudo pacman -S wtype"); - } else { - println!(" Install ydotool: sudo pacman -S ydotool"); + if output_status.primary_method.is_none() { + print_failure("No text output method available"); + if output_status.display_server == DisplayServer::Wayland { + println!(" Install wtype: sudo pacman -S wtype"); + } else { + println!(" Install ydotool: sudo pacman -S ydotool"); + } + all_ok = false; + } else if output_status.primary_method.as_deref() == Some("clipboard") { + print_warning("Only clipboard mode available - typing won't work"); + if output_status.display_server == DisplayServer::Wayland { + println!(" Install wtype: sudo pacman -S wtype"); + } else { + println!(" Install ydotool: sudo pacman -S ydotool"); + } } } + #[cfg(target_os = "macos")] + { + println!("\nPlatform:"); + print_info("Running on macOS - text output via external tools not yet implemented"); + print_info("Use 'voxtype record' commands with external keybindings"); + } + // Check whisper model println!("\nWhisper Model:"); let model_name = &config.whisper.model; From 5e10c629d90c1bd79b0d99e504b957dc834e9eed Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 22 Jan 2026 20:23:19 +0100 Subject: [PATCH 3/4] Fix HotkeyEvent::Pressed to use struct variant with model_override --- src/hotkey/macos.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hotkey/macos.rs b/src/hotkey/macos.rs index 368daeb..45b5513 100644 --- a/src/hotkey/macos.rs +++ b/src/hotkey/macos.rs @@ -406,7 +406,7 @@ fn macos_listener_loop( if modifiers_match && !is_pressed_clone.load(Ordering::SeqCst) { is_pressed_clone.store(true, Ordering::SeqCst); tracing::debug!("Hotkey pressed (macOS)"); - let _ = event_tx.send(HotkeyEvent::Pressed); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); } } } @@ -426,7 +426,7 @@ fn macos_listener_loop( if fn_pressed && !was_pressed { is_pressed_clone.store(true, Ordering::SeqCst); tracing::debug!("FN key pressed (macOS)"); - let _ = event_tx.send(HotkeyEvent::Pressed); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); } else if !fn_pressed && is_pressed_clone.load(Ordering::SeqCst) { is_pressed_clone.store(false, Ordering::SeqCst); tracing::debug!("FN key released (macOS)"); @@ -439,7 +439,7 @@ fn macos_listener_loop( if is_modifier_pressed && !is_pressed_clone.load(Ordering::SeqCst) { is_pressed_clone.store(true, Ordering::SeqCst); tracing::debug!("Modifier hotkey pressed (macOS)"); - let _ = event_tx.send(HotkeyEvent::Pressed); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); } else if !is_modifier_pressed && is_pressed_clone.load(Ordering::SeqCst) { is_pressed_clone.store(false, Ordering::SeqCst); tracing::debug!("Modifier hotkey released (macOS)"); From 26811467ea6a7a571b07f46a89721ec0ddd653a0 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 22 Jan 2026 12:17:16 +0100 Subject: [PATCH 4/4] Fix Metal backend crash with audio_ctx alignment The audio_ctx optimization formula could produce values not aligned to 8, causing GGML assertion failure (nb01 % 8 == 0) on Metal backend. For example, a 4-second audio clip would produce audio_ctx=266, which is not divisible by 8, causing a crash. Fix: Round up audio_ctx to the next multiple of 8 using the formula (raw_ctx + 7) / 8 * 8 This ensures compatibility with Metal backend alignment requirements while preserving the optimization benefits for short audio clips. --- src/transcribe/whisper.rs | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/transcribe/whisper.rs b/src/transcribe/whisper.rs index abad99d..6a23b4a 100644 --- a/src/transcribe/whisper.rs +++ b/src/transcribe/whisper.rs @@ -300,14 +300,20 @@ fn resolve_model_path(model: &str) -> Result { } /// Calculate audio_ctx parameter for short clips (≤22.5s). -/// Formula: duration_seconds * 50 + 64 +/// Formula: duration_seconds * 50 + 64, rounded up to next multiple of 8 /// /// This optimization reduces transcription time for short recordings by /// telling Whisper to use a smaller context window proportional to the /// actual audio length, rather than the full 30-second batch window. +/// +/// The result is aligned to 8 to satisfy Metal backend alignment requirements +/// (GGML requires nb01 % 8 == 0 for Metal operations). fn calculate_audio_ctx(duration_secs: f32) -> Option { if duration_secs <= 22.5 { - Some((duration_secs * 50.0) as i32 + 64) + let raw_ctx = (duration_secs * 50.0) as i32 + 64; + // Round up to next multiple of 8 for Metal backend alignment + let aligned_ctx = (raw_ctx + 7) / 8 * 8; + Some(aligned_ctx) } else { None } @@ -354,17 +360,23 @@ mod tests { #[test] fn test_calculate_audio_ctx_short_clips() { - // Very short clip: 1s -> 1 * 50 + 64 = 114 - assert_eq!(calculate_audio_ctx(1.0), Some(114)); + // Very short clip: 1s -> (1 * 50 + 64 = 114) rounded up to 120 + assert_eq!(calculate_audio_ctx(1.0), Some(120)); + + // 5 second clip: (5 * 50 + 64 = 314) rounded up to 320 + assert_eq!(calculate_audio_ctx(5.0), Some(320)); - // 5 second clip: 5 * 50 + 64 = 314 - assert_eq!(calculate_audio_ctx(5.0), Some(314)); + // 10 second clip: (10 * 50 + 64 = 564) rounded up to 568 + assert_eq!(calculate_audio_ctx(10.0), Some(568)); - // 10 second clip: 10 * 50 + 64 = 564 - assert_eq!(calculate_audio_ctx(10.0), Some(564)); + // At threshold: (22.5 * 50 + 64 = 1189) rounded up to 1192 + assert_eq!(calculate_audio_ctx(22.5), Some(1192)); - // At threshold: 22.5 * 50 + 64 = 1189 - assert_eq!(calculate_audio_ctx(22.5), Some(1189)); + // Verify all results are aligned to 8 (for Metal backend) + for duration in [1.0, 2.0, 3.0, 4.0, 5.0, 10.0, 15.0, 20.0, 22.5] { + let ctx = calculate_audio_ctx(duration).unwrap(); + assert_eq!(ctx % 8, 0, "audio_ctx {} not aligned to 8 for {}s", ctx, duration); + } } #[test] @@ -386,14 +398,14 @@ mod tests { // (the full 30-second context window). // // This test verifies the optimization logic by demonstrating: - // 1. When enabled: short clips get optimized audio_ctx (e.g., 114 for 1s) + // 1. When enabled: short clips get optimized audio_ctx (e.g., 120 for 1s) // 2. When disabled: Whisper's default 1500 is used (not set explicitly) const WHISPER_DEFAULT_AUDIO_CTX: i32 = 1500; - // With optimization enabled, 1s clip would use audio_ctx=114 + // With optimization enabled, 1s clip would use audio_ctx=120 (aligned to 8) let optimized_ctx = calculate_audio_ctx(1.0); - assert_eq!(optimized_ctx, Some(114)); + assert_eq!(optimized_ctx, Some(120)); assert!(optimized_ctx.unwrap() < WHISPER_DEFAULT_AUDIO_CTX); // With optimization disabled, we don't call calculate_audio_ctx,