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
343 changes: 264 additions & 79 deletions flake.nix

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ webrtc-vad = "0.4"
symphonia = { version = "0.6", features = ["mp3", "aac", "ogg", "vorbis", "flac", "wav", "pcm", "isomp4"] }

# Transcription
# Official k2-fsa crate; static + auto-download by default; macOS Parakeet TDT fallback
# (Linux CI builds without the parakeet feature).
sherpa-onnx = { version = "1.13", optional = true }
# Official k2-fsa crate. default-features = false drops its built-in `static`
# marker; sherpa-onnx-sys still defaults to static linking when no link feature
# is set (= the `parakeet` feature → CPU, unchanged). The `parakeet-cuda` feature
# switches it to `shared` so a GPU build can link a CUDA-enabled sherpa-onnx via
# the SHERPA_ONNX_LIB_DIR env var. (Linux CI builds without the parakeet feature.)
sherpa-onnx = { version = "1.13", default-features = false, optional = true }
# fluidaudio-rs: Apple Neural Engine backend via CoreML (macOS-only, Apple Silicon).
# WHY fork, not crates.io: the upstream crate lacks platform compile-guards (fails to
# build on Linux/Windows); the fork wraps the implementation in
Expand Down Expand Up @@ -127,6 +130,12 @@ objc2-core-foundation = { version = "0.3", features = ["CFString"] }
[features]
default = ["parakeet", "fluidaudio"]
parakeet = ["dep:sherpa-onnx"]
# GPU Parakeet (NVIDIA): links a CUDA-enabled sherpa-onnx (shared). Build with
# cargo build --no-default-features --features "parakeet-cuda,vulkan"
# and point SHERPA_ONNX_LIB_DIR at the k2-fsa CUDA prebuilt's lib dir. At runtime
# the recognizer requests the `cuda` provider and falls back to CPU if the EP
# isn't available. See flake.nix's `cuda` dev shell which wires this up.
parakeet-cuda = ["parakeet", "sherpa-onnx/shared"]
fluidaudio = ["dep:fluidaudio-rs"]
# GPU acceleration for whisper.cpp (mutually exclusive, only use one)
# Use: cargo build --features cuda for NVIDIA GPUs
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"core:window:allow-center",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-set-decorations",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"global-shortcut:default",
"dialog:default",
"fs:default",
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ pub struct GeneralConfig {
pub show_recording_indicator: bool,
/// Visual style for the recording indicator
pub indicator_style: IndicatorStyle,
/// Show native window decorations (Linux). When off, a custom in-app close
/// button is shown instead. macOS uses native traffic lights regardless.
#[serde(default = "default_true")]
pub window_decorations: bool,
/// App version recorded on the most recent run.
///
/// Used to detect that an update has been applied: when this differs from
Expand All @@ -345,6 +349,7 @@ impl Default for GeneralConfig {
check_for_updates: true,
show_recording_indicator: true,
indicator_style: IndicatorStyle::default(),
window_decorations: true,
last_run_version: None,
}
}
Expand Down Expand Up @@ -998,6 +1003,7 @@ mod tests {
check_for_updates: true,
show_recording_indicator: true,
indicator_style: IndicatorStyle::CursorDot,
window_decorations: true,
last_run_version: None,
},
recorder: RecorderConfig {
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,17 @@ pub fn run() {
#[cfg(target_os = "linux")]
text_insert::emit_linux_typing_advisory(&app_handle);

// Linux: apply the saved window-decoration preference at startup.
// With decorations off, the custom in-app close button (the
// WindowControls component) takes over.
#[cfg(target_os = "linux")]
if !cfg.general.window_decorations {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_decorations(false);
tracing::info!("Window decorations disabled per config");
}
}

// Start the Local Control API if enabled in config. The API and
// MCP server default on, so a fresh config arrives here enabled
// but with no token — generate and persist one so it just works
Expand Down
162 changes: 162 additions & 0 deletions src-tauri/src/shortcuts/hyprland.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//! Native Hyprland global shortcuts via `hyprctl` binds + a FIFO.
//!
//! On Hyprland the XDG GlobalShortcuts portal registers a shortcut but never
//! actually binds a key (the binding comes back empty), so global hotkeys never
//! fire. Instead we bind directly with `hyprctl keyword bind`, whose `exec`
//! dispatcher writes the shortcut id to a FIFO we listen on; each id is then
//! routed through [`super::manager::dispatch_shortcut_action`] — the same
//! dispatcher the X11/macOS plugin callback uses, so behaviour is identical.

use std::process::Command;
use tauri::{AppHandle, Runtime};

/// Whether the current session is Hyprland.
pub fn is_hyprland() -> bool {
std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some()
}

fn fifo_path() -> String {
match std::env::var("XDG_RUNTIME_DIR") {
Ok(dir) if !dir.is_empty() => format!("{dir}/thoth-shortcuts.fifo"),
_ => format!("/tmp/thoth-shortcuts-{}.fifo", std::process::id()),
}
}

/// Set up native Hyprland binds and the FIFO listener.
pub fn setup<R: Runtime>(app: &AppHandle<R>) {
start_fifo_listener(app.clone());
bind_shortcuts();
}

/// Convert a Tauri accelerator ("CommandOrControl+Shift+Space", "ShiftRight")
/// into a Hyprland "MODS, key" bind string. Modifier-only keys use keycodes,
/// which are more reliable than key names across keyboard layouts.
fn accelerator_to_hyprland_bind(accel: &str) -> String {
let mut modifiers = Vec::new();
let mut key = String::new();
for part in accel.split('+') {
match part {
"CommandOrControl" | "CmdOrCtrl" | "Control" | "Ctrl" => modifiers.push("CTRL"),
"Shift" => modifiers.push("SHIFT"),
"Alt" | "Option" => modifiers.push("ALT"),
"Meta" | "Super" | "Command" | "Cmd" => modifiers.push("SUPER"),
"ShiftRight" => key = "code:62".to_string(),
"ShiftLeft" => key = "code:42".to_string(),
"ControlRight" => key = "code:97".to_string(),
"ControlLeft" => key = "code:29".to_string(),
"AltRight" => key = "code:100".to_string(),
"AltLeft" => key = "code:56".to_string(),
other => key = other.to_lowercase(),
}
}
format!("{}, {}", modifiers.join(" "), key)
}

/// Modifier-only accelerators fire on release, so they need `bindr`, not `bind`.
fn is_modifier_only(accel: &str) -> bool {
matches!(
accel,
"ShiftRight" | "ShiftLeft" | "ControlRight" | "ControlLeft" | "AltRight" | "AltLeft"
)
}

/// Bind the configured shortcuts through `hyprctl`.
fn bind_shortcuts() {
use crate::shortcuts::manager::shortcut_ids;

let cfg = match crate::config::get_config() {
Ok(c) => c,
Err(e) => {
tracing::error!("Hyprland binds: failed to load config: {e}");
return;
}
};

let fifo = fifo_path();
let mut binds: Vec<(&str, String)> = vec![(
shortcut_ids::TOGGLE_RECORDING,
cfg.shortcuts.toggle_recording.clone(),
)];
if let Some(alt) = cfg.shortcuts.toggle_recording_alt.clone() {
binds.push((shortcut_ids::TOGGLE_RECORDING_ALT, alt));
}
if let Some(copy) = cfg.shortcuts.copy_last.clone() {
binds.push((shortcut_ids::COPY_LAST_TRANSCRIPTION, copy));
}

for (id, accel) in binds {
if accel.is_empty() {
continue;
}
let bind = accelerator_to_hyprland_bind(&accel);
let bind_type = if is_modifier_only(&accel) {
"bindr"
} else {
"bind"
};

// Clear any stale binds (both variants) before rebinding.
for unbind in ["unbind", "unbindr"] {
let _ = Command::new("hyprctl")
.args(["keyword", unbind, &bind])
.output();
}

let dispatch = format!("{bind}, exec, echo {id} > {fifo}");
match Command::new("hyprctl")
.args(["keyword", bind_type, &dispatch])
.output()
{
Ok(o) if o.status.success() => {
tracing::info!("Hyprland bind '{id}' -> {bind} ({bind_type})");
}
Ok(o) => tracing::error!(
"hyprctl {bind_type} failed for '{id}': {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => tracing::error!("hyprctl {bind_type} failed for '{id}': {e}"),
}
}
}

/// Create the FIFO and route each id written to it through the dispatcher.
fn start_fifo_listener<R: Runtime>(app: AppHandle<R>) {
use std::fs;
use std::io::{BufRead, BufReader};

let path = fifo_path();
let _ = fs::remove_file(&path);
match Command::new("mkfifo").arg(&path).status() {
Ok(s) if s.success() => {}
Ok(s) => {
tracing::error!("mkfifo failed with status {s}");
return;
}
Err(e) => {
tracing::error!("Failed to run mkfifo: {e}");
return;
}
}
tracing::info!("Hyprland shortcut FIFO listening at {path}");

std::thread::spawn(move || {
loop {
// Opening blocks until a writer (the hyprctl `exec`) opens the FIFO.
let file = match fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
tracing::error!("Failed to open FIFO for reading: {e}");
return;
}
};
for line in BufReader::new(file).lines().map_while(Result::ok) {
let id = line.trim();
if id.is_empty() {
continue;
}
crate::shortcuts::manager::dispatch_shortcut_action(&app, id);
}
// Writer closed (EOF) — loop back, reopen, wait for the next write.
}
});
}
12 changes: 10 additions & 2 deletions src-tauri/src/shortcuts/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,16 @@ pub fn get_display_server() -> DisplayServer {
/// the frontend via the `wayland-shortcuts-status` event.
pub fn init_global_shortcuts(app: &AppHandle) {
if get_display_server() == DisplayServer::Wayland {
tracing::info!("Wayland session: setting up the XDG GlobalShortcuts portal");
super::wayland_portal::setup(app);
// On Hyprland the XDG GlobalShortcuts portal registers a shortcut but
// never binds a key (the binding comes back empty), so hotkeys never
// fire. Use native hyprctl binds + a FIFO there instead of the portal.
if super::hyprland::is_hyprland() {
tracing::info!("Hyprland detected: using native hyprctl binds (skipping portal)");
super::hyprland::setup(app);
} else {
tracing::info!("Wayland session: setting up the XDG GlobalShortcuts portal");
super::wayland_portal::setup(app);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/shortcuts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
pub mod conflict;
pub mod manager;

#[cfg(target_os = "linux")]
pub mod hyprland;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "linux")]
Expand Down
Loading
Loading