diff --git a/flake.nix b/flake.nix index e52e911..9c84166 100644 --- a/flake.nix +++ b/flake.nix @@ -26,21 +26,125 @@ extensions = [ "rust-src" "rust-analyzer" ]; }; + # Build the package with this newer toolchain too β€” nixpkgs' default rustc + # (from the pinned nixpkgs) is too old for some deps (e.g. libsqlite3-sys + # uses the `cfg_select!` macro). + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + # CUDA packages for whisper.cpp GPU acceleration cudaPackages = pkgs.cudaPackages_12; - in { - devShells.default = pkgs.mkShell { - # Platform-specific library paths (Linux) + # CUDA-enabled sherpa-onnx prebuilt (k2-fsa release) for GPU Parakeet. + # The `parakeet-cuda` cargo feature links sherpa-onnx as `shared`, and + # SHERPA_ONNX_LIB_DIR points it here instead of downloading the CPU build. + # This archive ships libsherpa-onnx-c-api.so + libonnxruntime.so with the + # CUDA execution provider (libonnxruntime_providers_cuda.so). cuDNN/cudart + # are supplied via LD_LIBRARY_PATH in the `cuda` dev shell below. + sherpaOnnxCuda = pkgs.stdenvNoCC.mkDerivation { + pname = "sherpa-onnx-cuda"; + version = "1.13.2"; + src = pkgs.fetchurl { + url = "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.2/sherpa-onnx-v1.13.2-cuda-12.x-cudnn-9.x-linux-x64-gpu.tar.bz2"; + hash = "sha256-vRE8k6GLoPm24MrEaramoXYGvde3cbbq7gy9b5bOY/4="; + }; + dontConfigure = true; + dontBuild = true; + installPhase = "mkdir -p $out && cp -r lib $out/lib"; + }; + + # Dev-shell packages (shared by both shells). + commonPackages = with pkgs; [ + # Rust / Tauri + rustToolchain + cargo + rustc + rust-analyzer + + # Tauri dependencies (platform-specific) + openssl + pkg-config + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + # Linux-only Tauri dependencies + webkitgtk_4_1 + libappindicator-gtk3 + librsvg + alsa-lib + # whisper.cpp needs libclang for bindgen + llvmPackages.libclang + # X11 development libraries for x11rb (mouse tracking, display detection) + libx11 + libxcursor + libxrandr + libxi + # Vulkan for whisper.cpp GPU acceleration (AMD & NVIDIA) + vulkan-loader + vulkan-headers + vulkan-tools + # Shader compiler for Vulkan + shaderc + # CUDA toolkit for whisper.cpp CUDA acceleration (NVIDIA GPUs) + cudaPackages.cudatoolkit + cudaPackages.cuda_nvcc + cudaPackages.cuda_cudart + cudaPackages.cuda_cccl + cudaPackages.libcublas + # GCC for CUDA compilation + gcc + ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + # macOS: applesoft libraries (via Xcode) are used automatically + libiconv + # libclang for bindgen (whisper.cpp) + llvmPackages.libclang + ] ++ [ + # Frontend + nodejs_22 + pnpm + + # Build tools + cmake + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + glib + libsecret + # Native Wayland keyboard simulation (alternative to X11-based enigo) + wtype + ]; + + # whisper-rs-sys runs bindgen over ggml-vulkan.h. bindgen invokes libclang + # directly, bypassing the nix cc-wrapper, so it cannot find the libc headers + # (stdio.h) or clang's own builtin headers (stddef.h). bindgen then errors and + # whisper-rs-sys SILENTLY falls back to its bundled no-Vulkan bindings, so the + # ggml_backend_vk_* symbols go missing and whisper-rs fails to compile its + # Vulkan module (issue #64). Feed bindgen the cc-wrapper's libc flags plus + # clang's resource dir. A standard apt system finds these in /usr/include and + # lib/clang, so CI does not need this. + bindgenHook = pkgs.lib.optionalString pkgs.stdenv.isLinux '' + + export BINDGEN_EXTRA_CLANG_ARGS="$(< ${pkgs.stdenv.cc}/nix-support/libc-cflags) -idirafter ${pkgs.llvmPackages.libclang.lib}/lib/clang/${pkgs.lib.versions.major pkgs.llvmPackages.libclang.version}/include" + ''; + + # One dev-shell definition, optionally wired for GPU Parakeet (CUDA). + mkThothShell = { gpuParakeet ? false }: pkgs.mkShell ({ + # Platform-specific library paths (Linux). With gpuParakeet, also expose + # the CUDA sherpa-onnx libs + cuDNN so the CUDA execution provider loads. LD_LIBRARY_PATH = pkgs.lib.optionalString pkgs.stdenv.isLinux (pkgs.lib.makeLibraryPath ([ pkgs.libappindicator-gtk3 pkgs.vulkan-loader - ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ - # CUDA runtime libraries for whisper.cpp linking cudaPackages.cuda_cudart cudaPackages.cuda_cccl cudaPackages.libcublas + ] ++ pkgs.lib.optionals gpuParakeet [ + sherpaOnnxCuda # libsherpa-onnx-c-api.so + onnxruntime CUDA EP + cudaPackages.cudnn # libcudnn.so.9 + # The onnxruntime CUDA execution provider dlopen()s the full CUDA + # math-library set; a single missing one makes it abort (no CPU + # fallback), so provide all of them. + cudaPackages.libcurand # libcurand.so.10 + cudaPackages.libcufft # libcufft.so.11 + cudaPackages.libcusparse # libcusparse.so.12 ]) + ":/run/opengl-driver/lib"); # NVIDIA driver (libcuda.so) # Workaround for webkit2gtk Wayland issues (Linux only) @@ -57,71 +161,24 @@ # Linker search path for CUDA driver (libcuda.so) RUSTFLAGS = pkgs.lib.optionalString pkgs.stdenv.isLinux "-L /run/opengl-driver/lib"; - packages = with pkgs; [ - # Rust / Tauri - rustToolchain - cargo - rustc - rust-analyzer - - # Tauri dependencies (platform-specific) - openssl - pkg-config - ] ++ lib.optionals stdenv.isLinux [ - # Linux-only Tauri dependencies - webkitgtk_4_1 - libappindicator-gtk3 - librsvg - alsa-lib - # whisper.cpp needs libclang for bindgen - llvmPackages.libclang - # X11 development libraries for x11rb (mouse tracking, display detection) - libx11 - libxcursor - libxrandr - libxi - # Vulkan for whisper.cpp GPU acceleration (AMD & NVIDIA) - vulkan-loader - vulkan-headers - vulkan-tools - # Shader compiler for Vulkan - shaderc - # CUDA toolkit for whisper.cpp CUDA acceleration (NVIDIA GPUs) - cudaPackages.cudatoolkit - cudaPackages.cuda_nvcc - cudaPackages.cuda_cudart - cudaPackages.cuda_cccl - cudaPackages.libcublas - # GCC for CUDA compilation - gcc - ] ++ lib.optionals stdenv.isDarwin [ - # macOS: applesoft libraries (via Xcode) are used automatically - libiconv - # libclang for bindgen (whisper.cpp) - llvmPackages.libclang - ] ++ [ - # Frontend - nodejs_22 - pnpm - - # Build tools - cmake - - # Useful utilities (Linux-only) - ] ++ lib.optionals stdenv.isLinux [ - glib - libsecret - # Native Wayland keyboard simulation (alternative to X11-based enigo) - wtype - ]; + packages = commonPackages + ++ pkgs.lib.optionals (gpuParakeet && pkgs.stdenv.isLinux) [ cudaPackages.cudnn ]; shellHook = '' - echo "𓅝 Thoth Development Environment" + echo "𓅝 Thoth Development Environment${pkgs.lib.optionalString gpuParakeet " (GPU Parakeet / CUDA)"}" echo "================================" echo " Rust: $(rustc --version)" echo " Node: $(node --version)" echo " pnpm: $(pnpm --version)" echo "" + '' + (if gpuParakeet then '' + echo "GPU Parakeet (NVIDIA CUDA) is wired up. Build/run with:" + echo " pnpm tauri dev --no-default-features --features parakeet-cuda,vulkan" + echo " pnpm tauri build --no-default-features --features parakeet-cuda,vulkan" + echo "" + echo "Then transcribe and watch 'nvidia-smi' to confirm the GPU engages." + echo "Logs show 'Attempting CUDA provider...' / 'CUDA provider initialised'." + '' else '' echo "Commands:" echo " pnpm install - Install dependencies" echo " pnpm tauri dev - Start development build" @@ -130,22 +187,150 @@ echo " cargo test - Run Rust tests (from src-tauri/)" echo "" echo "GPU Acceleration (Linux):" - echo " --features cuda - NVIDIA GPUs (requires CUDA drivers)" - echo " --features hipblas - AMD GPUs (requires ROCm)" - echo " --features vulkan - Cross-platform (experimental)" - '' + pkgs.lib.optionalString pkgs.stdenv.isLinux '' - - # whisper-rs-sys runs bindgen over ggml-vulkan.h. bindgen invokes - # libclang directly, bypassing the nix cc-wrapper, so it cannot find - # the libc headers (stdio.h) or clang's own builtin headers - # (stddef.h). bindgen then errors and whisper-rs-sys SILENTLY falls - # back to its bundled no-Vulkan bindings, so the ggml_backend_vk_* - # symbols go missing and whisper-rs fails to compile its Vulkan - # module (issue #64). Feed bindgen the cc-wrapper's libc flags plus - # clang's resource dir. A standard apt system finds these in - # /usr/include and lib/clang, so CI does not need this. - export BINDGEN_EXTRA_CLANG_ARGS="$(< ${pkgs.stdenv.cc}/nix-support/libc-cflags) -idirafter ${pkgs.llvmPackages.libclang.lib}/lib/clang/${pkgs.lib.versions.major pkgs.llvmPackages.libclang.version}/include" + echo " --features cuda - NVIDIA GPUs (Whisper)" + echo " --features vulkan - Cross-platform (Whisper)" + echo " nix develop .#cuda - GPU Parakeet (NVIDIA, via sherpa-onnx CUDA)" + '') + bindgenHook; + } // pkgs.lib.optionalAttrs gpuParakeet { + # Make sherpa-onnx-sys link the CUDA libs instead of downloading CPU ones. + SHERPA_ONNX_LIB_DIR = "${sherpaOnnxCuda}/lib"; + }); + + # Runtime libraries the wrapped binary loads (CUDA EP + Vulkan + tray). + runtimeLibs = [ + pkgs.libappindicator-gtk3 + pkgs.vulkan-loader + sherpaOnnxCuda + cudaPackages.cudnn + cudaPackages.libcurand + cudaPackages.libcufft + cudaPackages.libcusparse + cudaPackages.cuda_cudart + cudaPackages.libcublas + ]; + + # --------------------------------------------------------------------- + # Installable, importable package: + # inputs.thoth.packages.${system}.default + # + # Builds GPU Parakeet (CUDA, via the prebuilt sherpa-onnx pinned above) + # plus Whisper (Vulkan). The binary is wrapped with the Wayland runtime + # tools (wl-clipboard, wtype) and the CUDA/Vulkan libraries it dlopen()s. + # `hyprctl` is taken from the user's PATH (present on any Hyprland system). + # + # Refresh the two hashes whenever Cargo.lock / pnpm-lock.yaml change: + # set both to lib.fakeHash, run `nix build`, paste the reported pnpmDeps + # hash, run again, paste the cargoHash, run once more to compile. + # --------------------------------------------------------------------- + thothPackage = rustPlatform.buildRustPackage (finalAttrs: { + pname = "thoth"; + version = "2026.6.3"; + src = ./.; + + cargoRoot = "src-tauri"; + buildAndTestSubdir = "src-tauri"; + # crates.io rate-limits the bulk vendor fetch (random 403s), so use + # cargoLock instead of cargoHash: each crate becomes its own fetchurl + # derivation β€” cached individually and retried by nix β€” so a throttled + # `nix build --max-jobs 2` makes steady progress through the limit. + # Registry checksums come from Cargo.lock; only the git dep needs a hash. + cargoLock = { + lockFile = ./src-tauri/Cargo.lock; + outputHashes = { + "fluidaudio-rs-0.10.0" = "sha256-z7c8tibtfevefrYAwh3hJM/sr/OWnbSrxjDS4Tda8+k="; + }; + }; + + # GPU Parakeet (sherpa-onnx CUDA via SHERPA_ONNX_LIB_DIR) + Whisper (Vulkan). + # No default features (drops fluidaudio, a macOS-only git dep, and the + # CPU `parakeet` link mode). + buildNoDefaultFeatures = true; + buildFeatures = [ "parakeet-cuda" "vulkan" ]; + + pnpmDeps = pkgs.fetchPnpmDeps { + inherit (finalAttrs) pname version src; + fetcherVersion = 3; + hash = "sha256-dDxzWfpqs2wuT6rSojfP7r0neOnCbkB3ha2fA45Icws="; + }; + + nativeBuildInputs = with pkgs; [ + cargo-tauri.hook + nodejs + pnpmConfigHook + pnpm + pkg-config + cmake + git + llvmPackages.libclang + shaderc # glslc β€” compiles whisper.cpp's Vulkan shaders + wrapGAppsHook4 + makeWrapper + ]; + + buildInputs = with pkgs; [ + openssl + webkitgtk_4_1 + glib + glib-networking + libsecret + libappindicator-gtk3 + alsa-lib + librsvg + libx11 + libxcursor + libxrandr + libxi + vulkan-loader + vulkan-headers + shaderc + sherpaOnnxCuda # Parakeet C API, linked via SHERPA_ONNX_LIB_DIR + ]; + + env = { + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + WEBKIT_DISABLE_COMPOSITING_MODE = "1"; + SHERPA_ONNX_LIB_DIR = "${sherpaOnnxCuda}/lib"; + }; + + # whisper-rs-sys bindgen needs the cc-wrapper libc flags + clang headers + # (issue #64); `$(< … )` must run at build time, so it lives here. + preConfigure = bindgenHook; + + # No updater signing key in the sandbox. + preBuild = '' + substituteInPlace src-tauri/tauri.conf.json \ + --replace-fail '"createUpdaterArtifacts": true' '"createUpdaterArtifacts": false' ''; - }; + + postFixup = '' + wrapProgram $out/bin/thoth \ + --prefix PATH : ${pkgs.lib.makeBinPath [ + pkgs.wl-clipboard # wl-copy / wl-paste + pkgs.wtype # Wayland keyboard simulation + pkgs.glib.bin # gsettings (theme detection) + pkgs.libcanberra-gtk3 # canberra-gtk-play (sound feedback) + ]} \ + --prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath runtimeLibs}:/run/opengl-driver/lib \ + --set WEBKIT_DISABLE_COMPOSITING_MODE 1 + ''; + + doCheck = false; # tests need audio hardware + + meta = with pkgs.lib; { + description = "Privacy-first, offline-capable voice transcription (GPU Parakeet + Whisper)"; + homepage = "https://github.com/poodle64/thoth"; + license = licenses.mit; + platforms = [ "x86_64-linux" ]; + mainProgram = "thoth"; + }; + }); + + in { + # `nix build` / `inputs.thoth.packages.${system}.default` + packages.default = thothPackage; + packages.thoth = thothPackage; + + devShells.default = mkThothShell { }; + devShells.cuda = mkThothShell { gpuParakeet = true; }; }); } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7110994..721cdd9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 @@ -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 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 82af4f9..7099050 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 944f310..99f964a 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -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 @@ -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, } } @@ -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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 029ef73..4daf5d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src-tauri/src/shortcuts/hyprland.rs b/src-tauri/src/shortcuts/hyprland.rs new file mode 100644 index 0000000..1003d67 --- /dev/null +++ b/src-tauri/src/shortcuts/hyprland.rs @@ -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(app: &AppHandle) { + 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(app: AppHandle) { + 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. + } + }); +} diff --git a/src-tauri/src/shortcuts/linux.rs b/src-tauri/src/shortcuts/linux.rs index 539dd40..94abb97 100644 --- a/src-tauri/src/shortcuts/linux.rs +++ b/src-tauri/src/shortcuts/linux.rs @@ -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); + } } } diff --git a/src-tauri/src/shortcuts/mod.rs b/src-tauri/src/shortcuts/mod.rs index b464484..40b1415 100644 --- a/src-tauri/src/shortcuts/mod.rs +++ b/src-tauri/src/shortcuts/mod.rs @@ -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")] diff --git a/src-tauri/src/text_insert.rs b/src-tauri/src/text_insert.rs index e0381b7..cb06d79 100644 --- a/src-tauri/src/text_insert.rs +++ b/src-tauri/src/text_insert.rs @@ -120,7 +120,31 @@ impl TextInsertService { fn insert_by_paste(&self, text: &str) -> Result<(), String> { debug!("Inserting {} characters by paste", text.len()); - // Set clipboard to new text + // On Wayland, arboard doesn't reliably serve clipboard content to other + // clients, and wtype/enigo paste keystrokes don't land in many apps on + // wlroots compositors. Use wl-copy for the clipboard and the compositor's + // own paste dispatch (hyprctl on Hyprland) instead. + #[cfg(target_os = "linux")] + if is_wayland() { + if !Self::wl_copy_set_clipboard(text) { + return Err("Failed to set clipboard via wl-copy".to_string()); + } + let pasted = if is_hyprland() { + Self::paste_with_hyprctl() + } else { + Self::try_paste_with_wtype() || Self::paste_with_enigo().is_ok() + }; + if pasted { + info!( + "Inserted {} characters via wl-copy + Wayland paste", + text.len() + ); + return Ok(()); + } + return Err("Failed to paste on Wayland".to_string()); + } + + // macOS / Linux X11: arboard clipboard + keystroke paste. let mut clipboard = arboard::Clipboard::new().map_err(|e| format!("Failed to access clipboard: {}", e))?; @@ -399,6 +423,117 @@ impl TextInsertService { debug!("Pasted via enigo (Ctrl+Shift+V)"); Ok(()) } + + /// Set the Wayland clipboard via `wl-copy` (serves content to all clients, + /// unlike arboard's ownership-based clipboard which other apps can't read). + #[cfg(target_os = "linux")] + fn wl_copy_set_clipboard(text: &str) -> bool { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut child = match Command::new("wl-copy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(e) => { + warn!("Failed to spawn wl-copy: {}", e); + return false; + } + }; + + if let Some(mut stdin) = child.stdin.take() { + if stdin.write_all(text.as_bytes()).is_err() { + return false; + } + } + + let ok = child.wait().map(|s| s.success()).unwrap_or(false); + if ok { + thread::sleep(Duration::from_millis(20)); + } + ok + } + + /// Paste on Hyprland: GUI apps via `hyprctl dispatch sendshortcut CTRL,v` + /// (avoids the layout-change notification a synthetic keypress can trigger); + /// terminals via `wtype` Ctrl+Shift+V (their paste binding). + #[cfg(target_os = "linux")] + fn paste_with_hyprctl() -> bool { + use std::process::{Command, Stdio}; + + if Self::active_window_is_terminal() { + Command::new("wtype") + .args([ + "-M", "ctrl", "-M", "shift", "v", "-m", "shift", "-m", "ctrl", + ]) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } else { + Command::new("hyprctl") + .args(["dispatch", "sendshortcut", "CTRL,v,activewindow"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + } + + /// Whether the active Hyprland window is a terminal emulator (terminals + /// paste with Ctrl+Shift+V, GUI apps with Ctrl+V). + #[cfg(target_os = "linux")] + fn active_window_is_terminal() -> bool { + use std::process::Command; + + let output = match Command::new("hyprctl") + .args(["activewindow", "-j"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return false, + }; + let json: serde_json::Value = match serde_json::from_slice(&output) { + Ok(v) => v, + Err(_) => return false, + }; + let class = json + .get("class") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + [ + "kitty", + "alacritty", + "foot", + "wezterm", + "terminal", + "konsole", + "xterm", + "terminator", + "ghostty", + ] + .iter() + .any(|t| class.contains(t)) + } +} + +/// Whether the current session is Wayland (Linux only). +#[cfg(target_os = "linux")] +fn is_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE") + .map(|s| s.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) +} + +/// Whether the compositor is Hyprland (Linux only). +#[cfg(target_os = "linux")] +fn is_hyprland() -> bool { + std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() } impl Default for TextInsertService { diff --git a/src-tauri/src/transcription/parakeet.rs b/src-tauri/src/transcription/parakeet.rs index 11a9598..1b0c8a7 100644 --- a/src-tauri/src/transcription/parakeet.rs +++ b/src-tauri/src/transcription/parakeet.rs @@ -85,7 +85,27 @@ impl TranscriptionService { } }; - #[cfg(not(target_os = "macos"))] + // Linux/Windows: with the `parakeet-cuda` feature, try the CUDA execution + // provider (NVIDIA GPU) first and fall back to CPU. Without it, CPU only. + // Note: onnxruntime silently falls back to CPU if the CUDA EP/libs aren't + // present, so confirm real GPU use via `nvidia-smi` during a transcription. + #[cfg(all(not(target_os = "macos"), feature = "parakeet-cuda"))] + let recognizer = { + tracing::info!("Attempting CUDA provider for GPU acceleration"); + match OfflineRecognizer::create(&build_config(Some("cuda".into()))) { + Some(r) => { + tracing::info!("CUDA provider initialised (verify GPU use with nvidia-smi)"); + r + } + None => { + tracing::warn!("CUDA provider failed, falling back to CPU"); + OfflineRecognizer::create(&build_config(Some("cpu".into()))) + .ok_or_else(|| anyhow!("Failed to create Parakeet recognizer with CPU"))? + } + } + }; + + #[cfg(all(not(target_os = "macos"), not(feature = "parakeet-cuda")))] let recognizer = { tracing::info!("Using CPU provider"); OfflineRecognizer::create(&build_config(Some("cpu".into()))) diff --git a/src/lib/components/OverviewPane.svelte b/src/lib/components/OverviewPane.svelte index 66fd004..fa9694f 100644 --- a/src/lib/components/OverviewPane.svelte +++ b/src/lib/components/OverviewPane.svelte @@ -7,6 +7,7 @@ */ import { onMount, onDestroy } from 'svelte'; import { invoke } from '@tauri-apps/api/core'; + import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { getVersion } from '@tauri-apps/api/app'; import { enable, disable, isEnabled } from '@tauri-apps/plugin-autostart'; @@ -97,6 +98,10 @@ let showInDock = $state(false); let dockLoading = $state(false); + /** Window decorations (Linux): native titlebar vs. custom close button */ + const isLinux = /Linux/.test(navigator.userAgent); + let windowDecorations = $state(configStore.general.windowDecorations ?? true); + /** Permission states */ let microphonePermission = $state<'unknown' | 'granted' | 'denied' | 'not_determined'>('unknown'); let accessibilityPermission = $state<'unknown' | 'granted' | 'denied' | 'stale'>('unknown'); @@ -175,6 +180,18 @@ } } + async function handleDecorationToggle(checked: boolean) { + windowDecorations = checked; + try { + await getCurrentWindow().setDecorations(checked); + configStore.general.windowDecorations = checked; + await configStore.save(); + } catch (error) { + console.error('Failed to toggle window decorations:', error); + windowDecorations = !checked; // revert on failure + } + } + let permissionChangedUnlisten: UnlistenFn | null = null; async function checkPermissions() { @@ -450,6 +467,26 @@ setupState = transcriptionReady ? 'ready' : 'needed'; isLoading = false; + // Poll for transcription readiness if the model is downloaded but not yet + // loaded (the backend warm-up thread may still be initialising). Without + // this the status sticks on "Loading…" until the next pane visit. + if (modelDownloaded && !transcriptionReady) { + const pollInterval = setInterval(async () => { + try { + const ready = await invoke('is_transcription_ready'); + if (ready) { + transcriptionReady = true; + setupState = 'ready'; + clearInterval(pollInterval); + } + } catch { + clearInterval(pollInterval); + } + }, 1000); + // Stop polling after 30 seconds regardless. + setTimeout(() => clearInterval(pollInterval), 30000); + } + // Ollama check runs separately to avoid blocking (30s timeout) if (!configStore.enhancement.enabled) { ollamaStatus = 'not-configured'; @@ -763,6 +800,12 @@ Show in Dock + {#if isLinux} +
+ Window Decorations + +
+ {/if} {:else if stats} @@ -1029,6 +1072,12 @@ Show in Dock + {#if isLinux} +
+ Window Decorations + +
+ {/if} diff --git a/src/lib/components/WindowControls.svelte b/src/lib/components/WindowControls.svelte new file mode 100644 index 0000000..cfe4e5a --- /dev/null +++ b/src/lib/components/WindowControls.svelte @@ -0,0 +1,49 @@ + + +
+ +
+ + diff --git a/src/lib/stores/config.svelte.ts b/src/lib/stores/config.svelte.ts index ab7f1ac..943cc30 100644 --- a/src/lib/stores/config.svelte.ts +++ b/src/lib/stores/config.svelte.ts @@ -105,6 +105,8 @@ export interface GeneralConfig { showRecordingIndicator: boolean; /** Visual style for the recording indicator */ indicatorStyle: IndicatorStyle; + /** Show native window decorations (Linux); custom close button when off */ + windowDecorations: boolean; } /** Recorder window position options */ @@ -192,6 +194,7 @@ interface ConfigRaw { check_for_updates: boolean; show_recording_indicator: boolean; indicator_style: IndicatorStyle; + window_decorations: boolean; }; recorder: { position: RecorderPosition; @@ -251,6 +254,7 @@ function parseConfig(raw: ConfigRaw): Config { checkForUpdates: raw.general.check_for_updates, showRecordingIndicator: raw.general.show_recording_indicator, indicatorStyle: raw.general.indicator_style, + windowDecorations: raw.general.window_decorations ?? true, }, recorder: { position: raw.recorder.position, @@ -311,6 +315,7 @@ function serialiseConfig(config: Config): ConfigRaw { check_for_updates: config.general.checkForUpdates, show_recording_indicator: config.general.showRecordingIndicator, indicator_style: config.general.indicatorStyle, + window_decorations: config.general.windowDecorations, }, recorder: { position: config.recorder.position, @@ -371,6 +376,7 @@ function getDefaultConfig(): Config { checkForUpdates: true, showRecordingIndicator: true, indicatorStyle: 'cursor-dot', + windowDecorations: true, }, recorder: { position: 'top-right', diff --git a/src/lib/windows/Settings.svelte b/src/lib/windows/Settings.svelte index cba9f52..cc4eb75 100644 --- a/src/lib/windows/Settings.svelte +++ b/src/lib/windows/Settings.svelte @@ -39,6 +39,7 @@ import { soundStore } from '../stores/sound.svelte'; import { Button } from '$components/ui/button'; import { Switch } from '$components/ui/switch'; + import WindowControls from '../components/WindowControls.svelte'; /** Settings pane definition */ interface SettingsPane { @@ -75,6 +76,10 @@ /** About dialog visibility */ let showAbout = $state(false); + /** Linux only β€” show the custom close button when decorations are disabled. */ + const isLinux = /Linux/.test(navigator.userAgent); + const showWindowControls = $derived(isLinux && !configStore.general.windowDecorations); + /** Map of shortcut IDs to their pending (unsaved) accelerators */ const pendingChanges = $state>(new Map()); @@ -260,6 +265,11 @@ {:else if pipelineStore.isProcessing} Processing... {/if} + {#if showWindowControls} +
+ +
+ {/if}
@@ -719,6 +729,16 @@ user-select: none; } + /* Pinned to the right edge so the centred title stays centred. */ + .title-bar-controls { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + } + .main-area { display: flex; flex: 1; diff --git a/vite.config.ts b/vite.config.ts index b157e31..3d131fe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,42 @@ import { defineConfig } from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; +import type { Plugin } from 'vite'; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; +/** + * Keep Tailwind out of Svelte's scoped component styles. + * + * A Svelte `