diff --git a/Cargo.toml b/Cargo.toml index 0d82943..fba24b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,8 @@ regex = "1" # Async traits async-trait = "0.1" -# Input handling (evdev for kernel-level key events) -evdev = "0.12" +# System utilities 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 @@ -127,6 +125,11 @@ omnilingual = ["onnx-common"] omnilingual-cuda = ["omnilingual", "ort/cuda"] omnilingual-tensorrt = ["omnilingual", "ort/tensorrt"] +# Linux-only: kernel-level input handling +[target.'cfg(target_os = "linux")'.dependencies] +evdev = "0.12" +inotify = "0.10" + [build-dependencies] clap = { version = "4", features = ["derive"] } clap_mangen = "0.2" diff --git a/src/config.rs b/src/config.rs index 6a1e469..b508cea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1808,10 +1808,10 @@ impl Config { /// Get the runtime directory for ephemeral files (state, sockets) pub fn runtime_dir() -> PathBuf { - // Use XDG_RUNTIME_DIR if available, otherwise fall back to /tmp + // Use XDG_RUNTIME_DIR if available, otherwise use platform-appropriate temp dir std::env::var("XDG_RUNTIME_DIR") .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) + .unwrap_or_else(|_| std::env::temp_dir()) .join("voxtype") } diff --git a/src/cpu.rs b/src/cpu.rs index a67ecd3..d39ac76 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -12,9 +12,10 @@ 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 ELF 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 2ebfdd5..fabb907 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -18,10 +18,8 @@ use crate::text::TextProcessor; use crate::transcribe::Transcriber; use pidlock::Pidlock; use std::path::PathBuf; -use std::process::Stdio; use std::sync::Arc; use std::time::Duration; -use tokio::process::Command; use tokio::signal::unix::{signal, SignalKind}; /// Send a desktop notification with optional engine icon @@ -37,12 +35,7 @@ async fn send_notification( title.to_string() }; - let _ = Command::new("notify-send") - .args(["--app-name=Voxtype", "--expire-time=2000", &title, body]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + crate::output::send_desktop_notification(&title, body).await; } /// Write state to file for external integrations (e.g., Waybar) diff --git a/src/error.rs b/src/error.rs index e2d7e2e..5e5a443 100644 --- a/src/error.rs +++ b/src/error.rs @@ -173,6 +173,7 @@ pub enum MeetingError { /// 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/mod.rs b/src/hotkey/mod.rs index 0420c40..a505102 100644 --- a/src/hotkey/mod.rs +++ b/src/hotkey/mod.rs @@ -1,11 +1,10 @@ //! 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. -//! -//! Requires the user to be in the 'input' group. +//! On Linux, provides kernel-level key event detection using evdev. +//! On macOS, hotkey detection is not built-in; use `voxtype record toggle` +//! bound to a system keyboard shortcut instead. +#[cfg(target_os = "linux")] pub mod evdev_listener; use crate::config::HotkeyConfig; @@ -38,6 +37,7 @@ pub trait HotkeyListener: Send + Sync { } /// Factory function to create the appropriate hotkey listener +#[cfg(target_os = "linux")] pub fn create_listener( config: &HotkeyConfig, secondary_model: Option, @@ -46,3 +46,18 @@ pub fn create_listener( listener.set_secondary_model(secondary_model); Ok(Box::new(listener)) } + +/// Factory function to create the appropriate hotkey listener +/// On macOS, built-in hotkey detection is not available. +#[cfg(not(target_os = "linux"))] +pub fn create_listener( + _config: &HotkeyConfig, + _secondary_model: Option, +) -> Result, HotkeyError> { + Err(HotkeyError::Evdev( + "Built-in hotkey detection is not available on macOS. \ + Set [hotkey] enabled = false in config.toml and use \ + 'voxtype record toggle' bound to a system keyboard shortcut." + .to_string(), + )) +} diff --git a/src/main.rs b/src/main.rs index e13ba0a..22cfc9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,14 +116,22 @@ async fn main() -> anyhow::Result<()> { model, default_model ); - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--expire-time=5000", - "Voxtype: Invalid Model", - &format!("Unknown model '{}', using '{}'", model, default_model), - ]) - .spawn(); + if cfg!(target_os = "macos") { + let script = format!( + "display notification \"Unknown model '{}', using '{}'\" with title \"Voxtype: Invalid Model\"", + model, default_model + ); + let _ = Command::new("osascript").args(["-e", &script]).spawn(); + } else { + let _ = Command::new("notify-send") + .args([ + "--app-name=Voxtype", + "--expire-time=5000", + "Voxtype: Invalid Model", + &format!("Unknown model '{}', using '{}'", model, default_model), + ]) + .spawn(); + } } } if let Some(engine) = cli.engine { @@ -898,8 +906,8 @@ fn is_daemon_running() -> bool { Err(_) => return false, // Invalid PID = not running }; - // Check if process exists by testing /proc/{pid} - std::path::Path::new(&format!("/proc/{}", pid)).exists() + // Check if process exists using kill(pid, 0) - works on all Unix platforms + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None).is_ok() } /// Run the status command - show current daemon state diff --git a/src/output/clipboard.rs b/src/output/clipboard.rs index 4b1b1fa..7f26287 100644 --- a/src/output/clipboard.rs +++ b/src/output/clipboard.rs @@ -1,9 +1,7 @@ //! Clipboard-based text output //! -//! Uses wl-copy to copy text to the Wayland clipboard. -//! This is the most reliable fallback as it works on all Wayland compositors. -//! -//! Requires: wl-clipboard package installed +//! On Linux, uses wl-copy to copy text to the Wayland clipboard. +//! On macOS, uses pbcopy to copy text to the system clipboard. use super::TextOutput; use crate::error::OutputError; @@ -11,6 +9,15 @@ use std::process::Stdio; use tokio::io::AsyncWriteExt; use tokio::process::Command; +/// Get the clipboard copy command for the current platform +fn clipboard_copy_cmd() -> &'static str { + if cfg!(target_os = "macos") { + "pbcopy" + } else { + "wl-copy" + } +} + /// Clipboard-based text output pub struct ClipboardOutput { /// Whether to show a desktop notification @@ -37,18 +44,7 @@ impl ClipboardOutput { text.to_string() }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--urgency=low", - "--expire-time=3000", - "Copied to clipboard", - &preview, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + super::send_desktop_notification("Copied to clipboard", &preview).await; } } @@ -66,8 +62,10 @@ impl TextOutput for ClipboardOutput { std::borrow::Cow::Borrowed(text) }; - // Spawn wl-copy with stdin pipe - let mut child = Command::new("wl-copy") + let cmd = clipboard_copy_cmd(); + + // Spawn clipboard command with stdin pipe + let mut child = Command::new(cmd) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -99,7 +97,7 @@ impl TextOutput for ClipboardOutput { if !status.success() { return Err(OutputError::InjectionFailed( - "wl-copy exited with error".to_string(), + format!("{} exited with error", cmd), )); } @@ -113,8 +111,9 @@ impl TextOutput for ClipboardOutput { } async fn is_available(&self) -> bool { + let cmd = clipboard_copy_cmd(); Command::new("which") - .arg("wl-copy") + .arg(cmd) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -124,7 +123,11 @@ impl TextOutput for ClipboardOutput { } fn name(&self) -> &'static str { - "clipboard (wl-copy)" + if cfg!(target_os = "macos") { + "clipboard (pbcopy)" + } else { + "clipboard (wl-copy)" + } } } diff --git a/src/output/dotool.rs b/src/output/dotool.rs index 5e9e5d3..4c0d901 100644 --- a/src/output/dotool.rs +++ b/src/output/dotool.rs @@ -68,17 +68,7 @@ impl DotoolOutput { preview }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--expire-time=3000", - "Transcribed", - &preview, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + super::send_desktop_notification("Transcribed", &preview).await; } /// Build the dotool command string to send via stdin diff --git a/src/output/mod.rs b/src/output/mod.rs index 22344a9..120fde8 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -103,6 +103,37 @@ pub fn engine_icon(engine: crate::config::TranscriptionEngine) -> &'static str { } } +/// Send a desktop notification (cross-platform) +pub async fn send_desktop_notification(title: &str, body: &str) { + if cfg!(target_os = "macos") { + // Use osascript on macOS + let script = format!( + "display notification \"{}\" with title \"{}\"", + body.replace('\\', "\\\\").replace('"', "\\\""), + title.replace('\\', "\\\\").replace('"', "\\\""), + ); + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } else { + let _ = Command::new("notify-send") + .args([ + "--app-name=Voxtype", + "--urgency=low", + "--expire-time=3000", + title, + body, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } +} + /// Send a transcription notification with optional engine icon pub async fn send_transcription_notification( text: &str, @@ -122,18 +153,7 @@ pub async fn send_transcription_notification( "Transcribed".to_string() }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--urgency=low", - "--expire-time=3000", - &title, - &preview, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + send_desktop_notification(&title, &preview).await; } /// Trait for text output implementations diff --git a/src/output/paste.rs b/src/output/paste.rs index 148b4cf..922b7a5 100644 --- a/src/output/paste.rs +++ b/src/output/paste.rs @@ -224,10 +224,15 @@ impl PasteOutput { } } - /// Copy text to clipboard using wl-copy + /// Copy text to clipboard async fn copy_to_clipboard(&self, text: &str) -> Result<(), OutputError> { - // Spawn wl-copy with stdin pipe - let mut child = Command::new("wl-copy") + let cmd = if cfg!(target_os = "macos") { + "pbcopy" + } else { + "wl-copy" + }; + + let mut child = Command::new(cmd) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -259,16 +264,21 @@ impl PasteOutput { if !status.success() { return Err(OutputError::InjectionFailed( - "wl-copy exited with error".to_string(), + format!("{} exited with error", cmd), )); } Ok(()) } - /// Read current clipboard content using wl-paste (Wayland) or xclip (X11 fallback) + /// Read current clipboard content async fn read_clipboard(&self) -> Result, OutputError> { - // Try wl-paste first (Wayland) + // macOS: use pbpaste + if cfg!(target_os = "macos") { + return self.read_clipboard_pbpaste().await; + } + + // Linux: try wl-paste first (Wayland), then xclip (X11) if std::env::var("WAYLAND_DISPLAY").is_ok() { match self.read_clipboard_wl_paste().await { Ok(content) => return Ok(content), @@ -282,6 +292,34 @@ impl PasteOutput { self.read_clipboard_xclip().await } + /// Read clipboard using pbpaste (macOS) + async fn read_clipboard_pbpaste(&self) -> Result, OutputError> { + let output = Command::new("pbpaste") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + + if !output.status.success() || output.stdout.is_empty() { + return Ok(None); + } + + const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; // 100 MB + if output.stdout.len() > MAX_CLIPBOARD_SIZE { + tracing::warn!( + "Clipboard content too large ({} bytes), skipping restoration", + output.stdout.len() + ); + return Ok(None); + } + + Ok(Some(ClipboardContent { + data: output.stdout, + mime_type: "text/plain".to_string(), + })) + } + /// Read clipboard using wl-paste async fn read_clipboard_wl_paste(&self) -> Result, OutputError> { // First, check if clipboard is empty by listing types @@ -402,12 +440,17 @@ impl PasteOutput { })) } - /// Restore clipboard content using wl-copy or xclip + /// Restore clipboard content async fn restore_clipboard_content( &self, content: &ClipboardContent, ) -> Result<(), OutputError> { - // Try wl-copy first (Wayland) + // macOS: use pbcopy + if cfg!(target_os = "macos") { + return self.restore_clipboard_pbcopy(content).await; + } + + // Linux: try wl-copy first (Wayland) if std::env::var("WAYLAND_DISPLAY").is_ok() { match self.restore_clipboard_wl_copy(content).await { Ok(()) => return Ok(()), @@ -421,6 +464,37 @@ impl PasteOutput { self.restore_clipboard_xclip(content).await } + /// Restore clipboard using pbcopy (macOS) + async fn restore_clipboard_pbcopy(&self, content: &ClipboardContent) -> Result<(), OutputError> { + let mut child = Command::new("pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&content.data) + .await + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + drop(stdin); + } + + let status = child + .wait() + .await + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + + if !status.success() { + return Err(OutputError::InjectionFailed( + "pbcopy exited with error during restore".to_string(), + )); + } + + Ok(()) + } + /// Restore clipboard using wl-copy with MIME type preservation async fn restore_clipboard_wl_copy( &self, @@ -627,9 +701,38 @@ impl PasteOutput { Ok(()) } - /// Simulate paste keystroke, trying wtype first then ydotool + /// Simulate paste keystroke using osascript (macOS) + async fn simulate_paste_osascript(&self) -> Result<(), OutputError> { + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to keystroke \"v\" using command down", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| OutputError::CtrlVFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(OutputError::CtrlVFailed(format!( + "osascript failed: {}. Grant Accessibility permissions in System Settings.", + stderr + ))); + } + + Ok(()) + } + + /// Simulate paste keystroke, trying platform-appropriate methods async fn simulate_paste_keystroke(&self) -> Result<(), OutputError> { - // Try wtype first (preferred - no daemon needed) + // macOS: use osascript + if cfg!(target_os = "macos") { + return self.simulate_paste_osascript().await; + } + + // Linux: try wtype first (preferred - no daemon needed) if self.is_wtype_available().await { match self.simulate_paste_wtype().await { Ok(()) => { @@ -663,7 +766,28 @@ impl PasteOutput { /// Send Enter key after paste async fn send_enter(&self) -> Result<(), OutputError> { - // Try wtype first + // macOS: use osascript + if cfg!(target_os = "macos") { + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to key code 36", // Return key + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await; + + if let Ok(out) = output { + if out.status.success() { + return Ok(()); + } + } + tracing::warn!("Failed to send Enter key via osascript"); + return Ok(()); + } + + // Linux: try wtype first if self.is_wtype_available().await { let output = Command::new("wtype") .args(["-k", "Return"]) @@ -788,7 +912,27 @@ impl TextOutput for PasteOutput { } async fn is_available(&self) -> bool { - // Check if wl-copy exists (required for clipboard) + // macOS: check for pbcopy + osascript (always available on macOS) + if cfg!(target_os = "macos") { + let pbcopy_available = Command::new("which") + .arg("pbcopy") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + + if !pbcopy_available { + tracing::debug!("paste mode unavailable: pbcopy not found"); + return false; + } + + tracing::debug!("paste mode available (macOS: pbcopy + osascript)"); + return true; + } + + // Linux: check if wl-copy exists (required for clipboard) let wl_copy_available = Command::new("which") .arg("wl-copy") .stdout(Stdio::null()) diff --git a/src/output/xclip.rs b/src/output/xclip.rs index d39b803..8389475 100644 --- a/src/output/xclip.rs +++ b/src/output/xclip.rs @@ -37,18 +37,7 @@ impl XclipOutput { text.to_string() }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--urgency=low", - "--expire-time=3000", - "Copied to clipboard", - &preview, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + super::send_desktop_notification("Copied to clipboard", &preview).await; } } diff --git a/src/output/ydotool.rs b/src/output/ydotool.rs index a5de470..734b0fd 100644 --- a/src/output/ydotool.rs +++ b/src/output/ydotool.rs @@ -83,17 +83,7 @@ impl YdotoolOutput { preview }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--expire-time=3000", - "Transcribed", - &preview, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + super::send_desktop_notification("Transcribed", &preview).await; } }