diff --git a/Cargo.toml b/Cargo.toml index e9b5037..42f8032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ include = [ "Cargo.toml", "Cargo.lock", "README.md", + "docs/install.md", + "docs/cli.md", + "docs/troubleshooting.md", "LICENSE", "NOTICE", "config.example.toml", diff --git a/README.md b/README.md index 470a994..cb6793e 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ · Quick start · - Commands + Docs · Troubleshooting · Releases

-`whispers` keeps the default dictation path local, with optional cloud ASR and rewrite backends when you want them. The normal loop is simple: start recording with a keybinding, stop recording, and paste the transcript directly into the focused app. +`whispers` keeps the default dictation path local, with optional cloud ASR and rewrite backends when you want them. The normal loop is simple: bind `whispers` to a key, press once to start recording, press again to stop, transcribe, and paste into the focused Wayland app. ## What it does @@ -34,66 +34,22 @@ ## Install -### Arch Linux (`paru`) - -#### CUDA-enabled - -```sh -# prebuilt GitHub release bundle with CUDA support -paru -S whispers-cuda-bin - -# latest main branch build with CUDA support -paru -S whispers-cuda-git -``` - -Use these when you want the CUDA-enabled local path from the AUR. +For the full package matrix, prerequisites, and post-install notes, see [docs/install.md](docs/install.md). -#### Portable / non-CUDA +### Arch Linux (`paru`) ```sh -# prebuilt GitHub release bundle paru -S whispers-bin - -# latest main branch build -paru -S whispers-git +# or: whispers-git / whispers-cuda-bin / whispers-cuda-git ``` -- `whispers-cuda-bin` installs the published Linux x86_64 CUDA bundle. -- `whispers-cuda-git` builds the latest `main` branch with `cuda,local-rewrite,osd`. -- `whispers-bin` installs the published portable non-CUDA Linux x86_64 bundle. -- `whispers-git` builds the latest `main` branch with the portable `local-rewrite,osd` feature set. - ### Cargo ```sh -# crates.io with the default OSD-enabled install cargo install whispers - -# add local rewrite support -cargo install whispers --features local-rewrite - -# add CUDA + local rewrite -cargo install whispers --features cuda,local-rewrite - -# no OSD -cargo install whispers --no-default-features ``` -If you want the latest GitHub version instead of crates.io: - -```sh -cargo install --git https://github.com/OneNoted/whispers --features local-rewrite -``` - -## Requirements - -- Linux with Wayland -- `wl-copy` -- access to `/dev/uinput` -- Rust 1.85+ -- CUDA toolkit if you enable the `cuda` feature - -If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedicated `uinput` group and `udev` rule for you. +`cargo install whispers` follows crates.io releases. The AUR `*-bin` packages follow published GitHub release bundles, and `*-git` packages track the repository `main` branch. If you need rewrite or CUDA features, or want install details before choosing a package, use [docs/install.md](docs/install.md). ## Quick start @@ -101,7 +57,7 @@ If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedic # generate config and download a model whispers setup -# one-shot dictation +# start dictation (run again to stop, transcribe, and paste) whispers ``` @@ -111,11 +67,7 @@ Default config path: ~/.config/whispers/config.toml ``` -Canonical example config: - -- [config.example.toml](config.example.toml) - -### Keybinding +Example compositor bindings: Hyprland: @@ -129,70 +81,26 @@ Sway: bindsym $mod+Alt+d exec whispers ``` -## Commands - -```sh -# setup -whispers setup - -# one-shot dictation -whispers -whispers transcribe audio.wav - -# ASR models -whispers asr-model list -whispers asr-model download large-v3-turbo -whispers asr-model select large-v3-turbo - -# rewrite models -whispers rewrite-model list -whispers rewrite-model download qwen-3.5-4b-q4_k_m -whispers rewrite-model select qwen-3.5-4b-q4_k_m - -# personalization -whispers dictionary add "wisper flow" "Wispr Flow" -whispers snippets add signature "Best regards,\nNotes" - -# cloud -whispers cloud check - -# shell completions -whispers completions zsh -``` - -## Notes +## Docs -- Local ASR is the default path. -- Local rewrite is enabled when you install with `--features local-rewrite` or use the current AUR packages. -- `whispers` installs the helper rewrite worker for you when that feature is enabled. -- Shell completions are printed to `stdout`. +- [Installation guide](docs/install.md) — package choices, prerequisites, config path, and feature notes. +- [CLI guide](docs/cli.md) — command groups, examples, and newer rewrite-policy commands. +- [Troubleshooting](docs/troubleshooting.md) — `wl-copy`, `/dev/uinput`, cloud checks, and hang diagnostics. +- [config.example.toml](config.example.toml) — the canonical config template. ## Troubleshooting -If the main `whispers` process ever gets stuck after playback when using local `whisper_cpp`, enable the built-in hang diagnostics for the next repro: +If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedicated `uinput` group and `udev` rule for you. If the main dictation process hangs around local `whisper_cpp` transcription, enable hang diagnostics for the next repro: ```sh WHISPERS_HANG_DEBUG=1 whispers ``` -When that mode is enabled, `whispers` writes runtime status and hang bundles under `${XDG_RUNTIME_DIR:-/tmp}/whispers/`: - -- `main-status.json` shows the current dictation stage and recent stage metadata. -- `hang---.log` is emitted if `whisper_cpp` spends too long in model load or transcription. - -Those bundles include the current status snapshot plus best-effort stack and open-file diagnostics. If the hang reproduces, capture the newest `hang-*.log` file along with `main-status.json`. +For the full troubleshooting guide, including the emitted `main-status.json` and `hang-*.log` files, see [docs/troubleshooting.md](docs/troubleshooting.md). ## Releases -Tagged releases publish a Linux x86_64 bundle with: - -- a portable `whispers--x86_64-unknown-linux-gnu.tar.gz` -- a CUDA-enabled `whispers-cuda--x86_64-unknown-linux-gnu.tar.gz` -- `whispers`, `whispers-osd`, and `whispers-rewrite-worker` -- Bash, Zsh, and Fish completions -- `README.md`, `config.example.toml`, `LICENSE`, and `NOTICE` - -Those bundles are what the `whispers-bin` and `whispers-cuda-bin` AUR packages install. +Tagged releases publish portable and CUDA-enabled Linux x86_64 bundles. The AUR `whispers-bin` and `whispers-cuda-bin` packages install those published release artifacts. ## License diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..954e32b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,100 @@ +# CLI guide + +For the full generated help, run `whispers --help` or `whispers --help`. + +## Top-level commands + +| Command | Purpose | +| --- | --- | +| `whispers` | Start or stop the default dictation loop | +| `whispers setup` | Guided setup for local, cloud, and experimental dictation paths | +| `whispers transcribe ` | Transcribe an audio file to stdout or a file | +| `whispers model` | Legacy `whisper_cpp` model commands | +| `whispers asr-model` | Manage ASR models across recommended and experimental backends | +| `whispers rewrite-model` | Manage local rewrite models | +| `whispers dictionary` | Manage deterministic replacements | +| `whispers app-rule` | Manage app-aware rewrite policy rules | +| `whispers glossary` | Manage technical glossary entries | +| `whispers cloud check` | Validate cloud config and connectivity | +| `whispers snippets` | Manage spoken snippets | +| `whispers rewrite-instructions-path` | Print the custom rewrite instructions file path | +| `whispers completions [shell]` | Print shell completions to stdout | + +## Common flows + +### Setup and dictation + +```sh +whispers setup +whispers +``` + +### File transcription + +```sh +whispers transcribe audio.wav +whispers transcribe meeting.m4a --output transcript.txt +whispers transcribe audio.wav --raw +``` + +### ASR model management + +```sh +whispers asr-model list +whispers asr-model download large-v3-turbo +whispers asr-model select large-v3-turbo +``` + +`whispers model` still exists for legacy `whisper_cpp` flows, but `whispers asr-model` is the preferred interface. + +### Rewrite model management + +```sh +whispers rewrite-model list +whispers rewrite-model download qwen-3.5-4b-q4_k_m +whispers rewrite-model select qwen-3.5-4b-q4_k_m +``` + +### Personalization + +```sh +whispers dictionary add "wisper flow" "Wispr Flow" +whispers snippets add signature "Best regards,\nNotes" +``` + +### Rewrite policy files + +```sh +whispers app-rule path +whispers glossary path +whispers rewrite-instructions-path +``` + +### App-aware rewrite rules + +```sh +whispers app-rule list +whispers app-rule add jira-browser \ + "Prefer issue IDs and preserve markdown punctuation" \ + --surface-kind browser \ + --browser-domain-contains atlassian.net +whispers app-rule remove jira-browser +``` + +### Technical glossary entries + +```sh +whispers glossary list +whispers glossary add serde_json \ + --alias "surdy json" \ + --alias "serdy json" \ + --surface-kind editor +whispers glossary remove serde_json +``` + +### Cloud and shell integration + +```sh +whispers cloud check +whispers completions zsh +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..9e7dfc6 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,108 @@ +# Install + +`whispers` targets Linux Wayland desktops and supports a few different installation paths depending on whether you want stable release bundles, the latest `main` branch, local rewrite, or CUDA. + +## Requirements + +- Linux with Wayland +- `wl-copy` (usually provided by `wl-clipboard`) +- Access to `/dev/uinput` for paste injection +- Rust 1.85+ for Cargo installs and source builds +- CUDA toolkit only when you build with the `cuda` feature yourself + +If `/dev/uinput` is not ready, run `whispers setup`. It can configure the dedicated `uinput` group and matching `udev` rule automatically. + +## Arch Linux (`paru`) + +### Portable / non-CUDA + +```sh +# published GitHub release bundle +paru -S whispers-bin + +# latest main branch build +paru -S whispers-git +``` + +### CUDA-enabled + +```sh +# published GitHub release bundle with CUDA support +paru -S whispers-cuda-bin + +# latest main branch build with CUDA support +paru -S whispers-cuda-git +``` + +### Package matrix + +| Package | Source | Feature set | +| --- | --- | --- | +| `whispers-bin` | Published GitHub release bundle | `local-rewrite,osd` | +| `whispers-git` | Latest `main` branch build | `local-rewrite,osd` | +| `whispers-cuda-bin` | Published GitHub CUDA release bundle | `cuda,local-rewrite,osd` | +| `whispers-cuda-git` | Latest `main` branch build with CUDA | `cuda,local-rewrite,osd` | + +## Cargo + +### crates.io + +```sh +# default install (OSD enabled) +cargo install whispers + +# add local rewrite support +cargo install whispers --features local-rewrite + +# add CUDA + local rewrite +cargo install whispers --features cuda,local-rewrite + +# no OSD +cargo install whispers --no-default-features +``` + +### GitHub source install + +If you want the current repository version instead of the latest crates.io publish: + +```sh +cargo install --git https://github.com/OneNoted/whispers --features local-rewrite +``` + +## After install + +Generate a config, download an ASR model, and walk through optional rewrite/cloud setup: + +```sh +whispers setup +``` + +Default config path: + +```text +~/.config/whispers/config.toml +``` + +Canonical example config: + +- [config.example.toml](../config.example.toml) + +## Keybinding examples + +Hyprland: + +```conf +bind = SUPER ALT, D, exec, whispers +``` + +Sway: + +```conf +bindsym $mod+Alt+d exec whispers +``` + +The first invocation starts recording. The next invocation stops recording, transcribes, and pastes into the currently focused app. + +## Release bundles + +Tagged releases publish portable and CUDA-enabled Linux x86_64 tarballs. The AUR `*-bin` packages install those published release artifacts rather than building from source on your machine. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..799f3af --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,65 @@ +# Troubleshooting + +## Start with setup + +If local dictation is not behaving correctly, rerun the guided setup first: + +```sh +whispers setup +``` + +That flow validates the current config, offers model downloads, and can fix `/dev/uinput` access for paste injection. + +## `wl-copy` is missing + +`whispers` uses `wl-copy` for clipboard injection. Install `wl-clipboard` (or otherwise make `wl-copy` available on `PATH`) and try again. + +## `/dev/uinput` is missing or not writable + +Paste injection depends on `/dev/uinput`. + +Recommended path: + +```sh +whispers setup +``` + +If you handle it manually instead, the fallback guidance is: + +- Load the kernel module: `sudo modprobe uinput` +- Persist it across reboots if needed: create `/etc/modules-load.d/whispers-uinput.conf` with `uinput` +- Create a dedicated `uinput` group and a `udev` rule for `/dev/uinput` +- Log out and back in after group membership changes + +## Cloud checks + +If cloud ASR or rewrite is configured but not working, validate the current provider, credentials, and reachability: + +```sh +whispers cloud check +``` + +## Inspect rewrite resource paths + +These helpers are useful when you want to confirm which runtime files the current config points at: + +```sh +whispers app-rule path +whispers glossary path +whispers rewrite-instructions-path +``` + +## Local `whisper_cpp` hang diagnostics + +If the main `whispers` process ever gets stuck after playback when using local `whisper_cpp`, enable the built-in hang diagnostics for the next repro: + +```sh +WHISPERS_HANG_DEBUG=1 whispers +``` + +When that mode is enabled, `whispers` writes runtime status and hang bundles under `${XDG_RUNTIME_DIR:-/tmp}/whispers/`: + +- `main-status.json` shows the current dictation stage and recent stage metadata. +- `hang---.log` is emitted if `whisper_cpp` spends too long in model load or transcription. + +Those bundles include the current status snapshot plus best-effort stack and open-file diagnostics. If the hang reproduces, capture the newest `hang-*.log` file along with `main-status.json`. diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index ae72dd3..85b3793 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -46,6 +46,7 @@ mkdir -p \ "$stage_dir/$bundle_name/share/bash-completion/completions" \ "$stage_dir/$bundle_name/share/zsh/site-functions" \ "$stage_dir/$bundle_name/share/fish/vendor_completions.d" \ + "$stage_dir/$bundle_name/share/doc/whispers/docs" \ "$stage_dir/$bundle_name/share/doc/whispers" \ "$stage_dir/$bundle_name/share/licenses/whispers" @@ -65,6 +66,12 @@ install -Dm755 "${target_dir}/whispers-rewrite-worker" \ install -Dm644 README.md \ "$stage_dir/$bundle_name/share/doc/whispers/README.md" +install -Dm644 docs/install.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/install.md" +install -Dm644 docs/cli.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/cli.md" +install -Dm644 docs/troubleshooting.md \ + "$stage_dir/$bundle_name/share/doc/whispers/docs/troubleshooting.md" install -Dm644 config.example.toml \ "$stage_dir/$bundle_name/share/doc/whispers/config.example.toml" install -Dm644 LICENSE \ diff --git a/src/inject/mod.rs b/src/inject/mod.rs index 97b19e1..fc6cc87 100644 --- a/src/inject/mod.rs +++ b/src/inject/mod.rs @@ -6,6 +6,8 @@ mod preflight; mod tests; use crate::error::{Result, WhsprError}; +#[cfg(test)] +pub(crate) use preflight::InjectionReadinessIssue; pub use preflight::{InjectionReadinessReport, validate_injection_prerequisites}; const DEVICE_READY_DELAY: std::time::Duration = std::time::Duration::from_millis(120); diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs index f182ab5..c63da8f 100644 --- a/src/inject/preflight.rs +++ b/src/inject/preflight.rs @@ -129,7 +129,10 @@ impl InjectionReadinessIssue { "Log out and back in after changing group membership.".into(), ], Self::UinputUnavailable(_) => { - vec!["Check that `/dev/uinput` exists and is writable by the current user.".into()] + vec![ + "Check that `/dev/uinput` exists and is readable and writable by the current user." + .into(), + ] } } } @@ -141,7 +144,7 @@ impl InjectionReadinessIssue { "`/dev/uinput` is missing; load the `uinput` kernel module".into() } Self::UinputPermissionDenied => { - "`/dev/uinput` is present but not writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() + "`/dev/uinput` is present but not readable and writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() } Self::UinputUnavailable(detail) => { format!("`/dev/uinput` could not be opened: {detail}") @@ -159,12 +162,15 @@ pub fn validate_injection_prerequisites() -> Result<()> { } fn probe_uinput() -> Option { - let path = Path::new(UINPUT_PATH); + probe_uinput_path(Path::new(UINPUT_PATH)) +} + +pub(super) fn probe_uinput_path(path: &Path) -> Option { if !path.exists() { return Some(InjectionReadinessIssue::MissingUinputDevice); } - match OpenOptions::new().write(true).open(path) { + match OpenOptions::new().read(true).write(true).open(path) { Ok(_) => None, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { Some(InjectionReadinessIssue::UinputPermissionDenied) diff --git a/src/inject/tests.rs b/src/inject/tests.rs index a655058..f1bc9d5 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -1,3 +1,5 @@ +use std::os::unix::fs::PermissionsExt; + use crate::error::WhsprError; use super::preflight::InjectionReadinessIssue; @@ -19,7 +21,7 @@ fn run_wl_copy_reports_spawn_failure() { fn run_wl_copy_reports_non_zero_exit() { let err = clipboard::run_wl_copy( "/bin/sh", - &[String::from("-c"), String::from("exit 7")], + &[String::from("-c"), String::from("cat >/dev/null; exit 7")], "hello", ) .expect_err("non-zero exit should fail"); @@ -112,3 +114,22 @@ fn readiness_report_only_requires_relogin_when_permission_is_last_blocker() { ]); assert!(!additional_steps.only_requires_relogin()); } + +#[test] +fn probe_uinput_requires_read_and_write_access() { + let dir = crate::test_support::unique_temp_dir("uinput-readwrite-probe"); + let path = dir.join("uinput"); + std::fs::write(&path, []).expect("create probe file"); + + let mut perms = std::fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o200); + std::fs::set_permissions(&path, perms).expect("set write-only permissions"); + + let issue = preflight::probe_uinput_path(&path).expect("write-only file should fail"); + assert_eq!(issue, InjectionReadinessIssue::UinputPermissionDenied); + + let mut cleanup_perms = std::fs::metadata(&path).expect("metadata").permissions(); + cleanup_perms.set_mode(0o600); + std::fs::set_permissions(&path, cleanup_perms).expect("restore permissions"); + std::fs::remove_dir_all(&dir).expect("cleanup temp dir"); +} diff --git a/src/setup.rs b/src/setup.rs index 3d7028d..047c7c4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -54,7 +54,13 @@ impl CloudSetup { } } +fn validate_setup_invoker(uid: libc::uid_t) -> Result<()> { + side_effects::validate_setup_user(uid) +} + pub async fn run_setup(config_path_override: Option<&Path>) -> Result<()> { + validate_setup_invoker(unsafe { libc::geteuid() })?; + let ui = SetupUi::new(); ui.print_header("whispers setup"); ui.blank(); diff --git a/src/setup/report.rs b/src/setup/report.rs index d6a1dfd..7f0823c 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -87,17 +87,37 @@ pub(super) fn print_injection_readiness( println!(" - {line}"); } - if setup.changed_groups { - ui.print_info( - "If you were just added to the `uinput` group, log out and back in before testing.", - ); + if let Some(message) = injection_readiness_info_message(readiness, setup) { + ui.print_info(message); } - for line in readiness.fix_lines() { + for line in injection_readiness_fix_lines(readiness, setup) { println!(" - {line}"); } } +pub(super) fn injection_readiness_info_message( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Option<&'static str> { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Some("Log out and back in before testing.") + } else { + setup.report_group_change_message() + } +} + +pub(super) fn injection_readiness_fix_lines( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Vec { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Vec::new() + } else { + readiness.fix_lines() + } +} + pub(super) fn print_setup_complete( ui: &SetupUi, readiness: &InjectionReadinessReport, @@ -109,7 +129,7 @@ pub(super) fn print_setup_complete( setup_complete_message( readiness.is_ready(), setup.changed_groups, - readiness.only_requires_relogin(), + setup.can_finish_with_relogin_only(readiness.only_requires_relogin()), ) ); ui.print_section("Example keybind"); @@ -120,11 +140,11 @@ pub(super) fn print_setup_complete( pub(super) fn setup_complete_message( is_ready: bool, changed_groups: bool, - only_requires_relogin: bool, + can_finish_with_relogin_only: bool, ) -> &'static str { if is_ready { "You can now use whispers." - } else if changed_groups && only_requires_relogin { + } else if can_finish_with_relogin_only { "Log out and back in, then use whispers." } else if changed_groups { "Log out and back in, then finish any remaining paste injection steps above before using whispers." diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index ca58900..efbdd8e 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,4 +1,4 @@ -use std::ffi::CStr; +use std::ffi::{CStr, CString}; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; @@ -14,10 +14,52 @@ const MODULES_LOAD_PATH: &str = "/etc/modules-load.d/whispers-uinput.conf"; const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/70-whispers-uinput.rules"; const UDEV_RULE_CONTENT: &str = "KERNEL==\"uinput\", SUBSYSTEM==\"misc\", GROUP=\"uinput\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"\n"; const MODULES_LOAD_CONTENT: &str = "uinput\n"; +pub(super) const UDEV_TRIGGER_ARGS: &[&str] = &[ + "udevadm", + "trigger", + "--subsystem-match=misc", + "--sysname-match=uinput", + "--settle", +]; #[derive(Debug, Clone, Copy, Default)] pub(super) struct InjectionSetupOutcome { pub changed_groups: bool, + pub group_membership_ready: bool, + pub uinput_rule_ready: bool, + pub udev_reload_succeeded: bool, +} + +impl InjectionSetupOutcome { + pub(super) fn setup_group_change_message(self) -> Option<&'static str> { + if !self.changed_groups { + None + } else if self.udev_reload_succeeded { + Some("Group membership changed. Log out and back in before testing dictation.") + } else { + Some( + "Group membership changed. Log out and back in after finishing the remaining paste injection steps.", + ) + } + } + + pub(super) fn report_group_change_message(self) -> Option<&'static str> { + if !self.changed_groups { + None + } else { + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ) + } + } + + pub(super) fn can_finish_with_relogin_only(self, only_requires_relogin: bool) -> bool { + self.should_reload_udev() && self.udev_reload_succeeded && only_requires_relogin + } + + pub(super) fn should_reload_udev(self) -> bool { + self.group_membership_ready && self.uinput_rule_ready + } } pub(super) async fn download_asr_model( @@ -101,6 +143,7 @@ pub(super) fn maybe_prewarm_experimental_nemo( } pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result { + validate_setup_user(unsafe { libc::geteuid() })?; let readiness = crate::inject::InjectionReadinessReport::collect(); if readiness.is_ready() || !readiness.has_uinput_issue() { return Ok(InjectionSetupOutcome::default()); @@ -124,10 +167,11 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.uinput_rule_ready = true, + Err(err) => ui.print_warn(format!( "Failed to install the `/dev/uinput` udev rule: {err}" - )); + )), } if let Err(err) = ensure_group_exists(ui, UINPUT_GROUP) { ui.print_warn(format!( @@ -141,14 +185,25 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.udev_reload_succeeded = true, + Err(err) => ui.print_warn(format!( + "Failed to reload `udev` after updating `/dev/uinput`: {err}" + )), + } + } else if !outcome.group_membership_ready { + ui.print_warn( + "Skipping `/dev/uinput` rule activation until `uinput` group membership is in place.", + ); + } else if !outcome.uinput_rule_ready { + ui.print_warn( + "Skipping `/dev/uinput` rule activation until the `uinput` udev rule is installed.", + ); } - if outcome.changed_groups { - ui.print_info("Group membership changed. Log out and back in before testing dictation."); + if let Some(message) = outcome.setup_group_change_message() { + ui.print_info(message); } Ok(outcome) @@ -162,9 +217,13 @@ pub(super) fn record_group_membership_change_result( match result { Ok(true) => { outcome.changed_groups = true; + outcome.group_membership_ready = true; + None + } + Ok(false) => { + outcome.group_membership_ready = true; None } - Ok(false) => None, Err(err) => Some(format!( "Failed to add the current user to the `{group}` group: {err}" )), @@ -185,12 +244,7 @@ fn ensure_uinput_module_loaded(ui: &SetupUi) -> Result<()> { fn reload_udev(ui: &SetupUi) -> Result<()> { ui.print_info("Reloading `udev` rules for `/dev/uinput`..."); run_sudo(&["udevadm", "control", "--reload"])?; - run_sudo(&[ - "udevadm", - "trigger", - "--subsystem-match=misc", - "--sysname-match=uinput", - ]) + run_sudo(UDEV_TRIGGER_ARGS) } fn add_user_to_group(ui: &SetupUi, group: &str) -> Result { @@ -219,15 +273,52 @@ fn ensure_group_exists(ui: &SetupUi, group: &str) -> Result<()> { run_sudo(&["groupadd", "--system", group]) } -fn group_exists(group: &str) -> Result { - let group_file = std::fs::read_to_string("/etc/group").map_err(|err| { - crate::error::WhsprError::Config(format!("failed to read /etc/group: {err}")) +pub(super) fn group_exists(group: &str) -> Result { + let c_group = CString::new(group).map_err(|_| { + crate::error::WhsprError::Config(format!( + "group name `{group}` contains an interior NUL byte" + )) })?; - Ok(group_file - .lines() - .filter_map(|line| line.split(':').next()) - .any(|entry| entry == group)) + let mut buffer_len = match unsafe { libc::sysconf(libc::_SC_GETGR_R_SIZE_MAX) } { + value if value > 0 => value as usize, + _ => 1024, + }; + + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut group_entry = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = unsafe { + libc::getgrnam_r( + c_group.as_ptr(), + group_entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + return Ok(!result.is_null()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: {}", + std::io::Error::from_raw_os_error(status) + ))); + } } fn current_user_in_group(username: &str, group: &str) -> Result { @@ -249,41 +340,79 @@ fn current_user_in_group(username: &str, group: &str) -> Result { fn current_username() -> Result { let uid = unsafe { libc::geteuid() }; + validate_setup_user(uid)?; + + current_username_for_uid_with(uid, |uid, passwd, buffer, result| unsafe { + libc::getpwuid_r( + uid, + passwd, + buffer.as_mut_ptr().cast(), + buffer.len(), + result, + ) + }) +} + +pub(super) fn validate_setup_user(uid: libc::uid_t) -> Result<()> { if uid == 0 { return Err(crate::error::WhsprError::Config( "run `whispers setup` as your normal user, not as root".into(), )); } - let buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { + Ok(()) +} + +pub(super) fn current_username_for_uid_with(uid: libc::uid_t, mut lookup: F) -> Result +where + F: FnMut(libc::uid_t, *mut libc::passwd, &mut Vec, *mut *mut libc::passwd) -> libc::c_int, +{ + let mut buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { value if value > 0 => value as usize, _ => 1024, }; - let mut buffer = vec![0u8; buffer_len]; - let mut passwd = std::mem::MaybeUninit::::uninit(); - let mut result = std::ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - if status != 0 || result.is_null() { + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut passwd = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = lookup(uid, passwd.as_mut_ptr(), &mut buffer, &mut result); + if status == 0 && !result.is_null() { + let passwd = unsafe { passwd.assume_init() }; + let name = unsafe { CStr::from_ptr(passwd.pw_name) }; + return Ok(name.to_string_lossy().into_owned()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + if status == 0 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}" + ))); + } + return Err(crate::error::WhsprError::Config(format!( - "failed to resolve current username for uid {uid}" + "failed to resolve current username for uid {uid}: {}", + std::io::Error::from_raw_os_error(status) ))); } - - let passwd = unsafe { passwd.assume_init() }; - let name = unsafe { CStr::from_ptr(passwd.pw_name) }; - Ok(name.to_string_lossy().into_owned()) } fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { + if root_file_has_contents(Path::new(target), contents) { + ui.print_info(format!("`{target}` already has the expected contents.")); + return Ok(()); + } + let Some(parent) = Path::new(target).parent().and_then(|path| path.to_str()) else { return Err(crate::error::WhsprError::Config(format!( "failed to determine parent directory for `{target}`" @@ -296,6 +425,21 @@ fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { Ok(()) } +fn root_file_has_contents(path: &Path, expected: &str) -> bool { + match std::fs::read_to_string(path) { + Ok(contents) => contents == expected, + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + false + } + Err(_) => false, + } +} + fn run_sudo(args: &[&str]) -> Result<()> { let status = Command::new("sudo").args(args).status().map_err(|err| { crate::error::WhsprError::Config(format!("failed to run sudo {:?}: {err}", args)) diff --git a/src/setup/tests.rs b/src/setup/tests.rs index e167206..2e25ed4 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::error::WhsprError; +use crate::inject::{InjectionReadinessIssue, InjectionReadinessReport}; use super::{CloudSetup, apply, report, side_effects}; use crate::config::{self, TranscriptionBackend, TranscriptionFallback}; @@ -63,10 +64,34 @@ fn group_membership_failures_become_warnings_without_marking_success() { .expect("errors should become warnings"); assert!(!outcome.changed_groups); + assert!(!outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); assert!(warning.contains("Failed to add the current user")); assert!(warning.contains("group add failed")); } +#[test] +fn setup_rejects_root_before_uinput_readiness_short_circuit() { + let err = side_effects::validate_setup_user(0).expect_err("root should be rejected"); + match err { + WhsprError::Config(message) => { + assert!(message.contains("run `whispers setup` as your normal user, not as root")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[test] +fn setup_rejects_root_before_any_setup_side_effects() { + let err = super::validate_setup_invoker(0).expect_err("root should be rejected up front"); + match err { + WhsprError::Config(message) => { + assert!(message.contains("run `whispers setup` as your normal user, not as root")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + #[test] fn group_membership_success_marks_logout_as_needed() { let mut outcome = side_effects::InjectionSetupOutcome::default(); @@ -75,6 +100,199 @@ fn group_membership_success_marks_logout_as_needed() { assert!(warning.is_none()); assert!(outcome.changed_groups); + assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); + assert!(!outcome.udev_reload_succeeded); +} + +#[test] +fn group_exists_uses_nss_sources() { + assert!(side_effects::group_exists("root").expect("resolve root group")); + + let missing = format!("whispers-missing-group-{}", std::process::id()); + assert!(!side_effects::group_exists(&missing).expect("resolve missing group")); +} + +#[test] +fn existing_group_membership_marks_relogin_as_possible_without_new_group_change() { + let mut outcome = side_effects::InjectionSetupOutcome::default(); + let warning = + side_effects::record_group_membership_change_result(&mut outcome, "uinput", Ok(false)); + + assert!(warning.is_none()); + assert!(!outcome.changed_groups); + assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); +} + +#[test] +fn group_change_messages_follow_recorded_reload_status() { + let success = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let failed_reload = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + + assert_eq!( + success.setup_group_change_message(), + Some("Group membership changed. Log out and back in before testing dictation."), + ); + assert_eq!( + failed_reload.setup_group_change_message(), + Some( + "Group membership changed. Log out and back in after finishing the remaining paste injection steps.", + ), + ); + assert_eq!( + success.report_group_change_message(), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ), + ); + assert_eq!( + failed_reload.report_group_change_message(), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ), + ); +} + +#[test] +fn relogin_only_readiness_collapses_to_logout_instruction() { + let readiness = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + let setup = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + + assert_eq!( + report::injection_readiness_info_message(&readiness, &setup), + Some("Log out and back in before testing.") + ); + assert!(report::injection_readiness_fix_lines(&readiness, &setup).is_empty()); +} + +#[test] +fn relogin_only_completion_allows_reruns_when_group_is_already_configured() { + let rerun = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let failed_group_update = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: false, + uinput_rule_ready: true, + udev_reload_succeeded: true, + }; + let missing_rule = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: true, + }; + + assert!(rerun.can_finish_with_relogin_only(true)); + assert!(!failed_group_update.can_finish_with_relogin_only(true)); + assert!(!missing_rule.can_finish_with_relogin_only(true)); + assert!(!rerun.can_finish_with_relogin_only(false)); +} + +#[test] +fn udev_reload_requires_group_membership_and_rule() { + let ready = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + let missing_group = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: false, + uinput_rule_ready: true, + udev_reload_succeeded: false, + }; + let missing_rule = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: false, + }; + + assert!(ready.should_reload_udev()); + assert!(!missing_group.should_reload_udev()); + assert!(!missing_rule.should_reload_udev()); +} + +#[test] +fn incomplete_setup_keeps_manual_fix_lines_visible() { + let readiness = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + let setup = side_effects::InjectionSetupOutcome { + changed_groups: true, + group_membership_ready: true, + uinput_rule_ready: false, + udev_reload_succeeded: true, + }; + + assert_eq!( + report::injection_readiness_info_message(&readiness, &setup), + Some( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ) + ); + assert!(!report::injection_readiness_fix_lines(&readiness, &setup).is_empty()); +} + +#[test] +fn current_username_lookup_retries_after_erange() { + let expected_name = std::ffi::CString::new("whispers-test-user").expect("c string"); + let mut attempts = 0; + + let username = + side_effects::current_username_for_uid_with(4242, |uid, passwd, _buffer, result| { + attempts += 1; + if attempts == 1 { + return libc::ERANGE; + } + + unsafe { + *passwd = libc::passwd { + pw_name: expected_name.as_ptr() as *mut _, + pw_passwd: std::ptr::null_mut(), + pw_uid: uid, + pw_gid: 0, + pw_gecos: std::ptr::null_mut(), + pw_dir: std::ptr::null_mut(), + pw_shell: std::ptr::null_mut(), + }; + *result = passwd; + } + + 0 + }) + .expect("retry should succeed"); + + assert_eq!(username, "whispers-test-user"); + assert_eq!(attempts, 2); +} + +#[test] +fn udev_trigger_waits_for_settle_before_rechecking() { + assert!(side_effects::UDEV_TRIGGER_ARGS.contains(&"--settle")); } #[test] @@ -87,6 +305,14 @@ fn setup_complete_message_stays_aligned_with_remaining_steps() { report::setup_complete_message(false, true, false), "Log out and back in, then finish any remaining paste injection steps above before using whispers." ); + assert_eq!( + report::setup_complete_message(false, true, false), + "Log out and back in, then finish any remaining paste injection steps above before using whispers." + ); + assert_eq!( + report::setup_complete_message(false, false, true), + "Log out and back in, then use whispers." + ); assert_eq!( report::setup_complete_message(false, false, false), "Finish the paste injection steps above, then use whispers."