Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
3 changes: 2 additions & 1 deletion src/cpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() = {
Expand Down
9 changes: 1 addition & 8 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ pub enum MeetingError {
/// Result type alias using VoxtypeError
pub type Result<T> = std::result::Result<T, VoxtypeError>;

#[cfg(target_os = "linux")]
impl From<evdev::Error> for HotkeyError {
fn from(e: evdev::Error) -> Self {
HotkeyError::Evdev(e.to_string())
Expand Down
25 changes: 20 additions & 5 deletions src/hotkey/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String>,
Expand All @@ -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<String>,
) -> Result<Box<dyn HotkeyListener>, 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(),
))
}
28 changes: 18 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
45 changes: 24 additions & 21 deletions src/output/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
//! 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;
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
Expand All @@ -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;
}
}

Expand All @@ -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())
Expand Down Expand Up @@ -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),
));
}

Expand All @@ -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()
Expand All @@ -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)"
}
}
}

Expand Down
12 changes: 1 addition & 11 deletions src/output/dotool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 32 additions & 12 deletions src/output/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading