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 e6e99c7..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,70 +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, add your user to the `input` group and log back in: - -```sh -sudo usermod -aG input "$USER" -``` +`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 @@ -105,7 +57,7 @@ sudo usermod -aG input "$USER" # generate config and download a model whispers setup -# one-shot dictation +# start dictation (run again to stop, transcribe, and paste) whispers ``` @@ -115,11 +67,7 @@ Default config path: ~/.config/whispers/config.toml ``` -Canonical example config: - -- [config.example.toml](config.example.toml) - -### Keybinding +Example compositor bindings: Hyprland: @@ -133,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 0df955b..fc6cc87 100644 --- a/src/inject/mod.rs +++ b/src/inject/mod.rs @@ -1,10 +1,14 @@ mod clipboard; mod keyboard; +mod preflight; #[cfg(test)] 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); const CLIPBOARD_READY_DELAY: std::time::Duration = std::time::Duration::from_millis(180); diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs new file mode 100644 index 0000000..c63da8f --- /dev/null +++ b/src/inject/preflight.rs @@ -0,0 +1,194 @@ +use std::env; +use std::fs; +use std::fs::OpenOptions; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use crate::error::{Result, WhsprError}; + +const UINPUT_PATH: &str = "/dev/uinput"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InjectionReadinessReport { + issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum InjectionReadinessIssue { + MissingWlCopy, + MissingUinputDevice, + UinputPermissionDenied, + UinputUnavailable(String), +} + +impl InjectionReadinessReport { + pub fn collect() -> Self { + let mut issues = Vec::new(); + + if !command_on_path("wl-copy") { + issues.push(InjectionReadinessIssue::MissingWlCopy); + } + + if let Some(issue) = probe_uinput() { + issues.push(issue); + } + + Self { issues } + } + + pub fn is_ready(&self) -> bool { + self.issues.is_empty() + } + + pub(crate) fn has_uinput_issue(&self) -> bool { + self.issues.iter().any(|issue| { + matches!( + issue, + InjectionReadinessIssue::MissingUinputDevice + | InjectionReadinessIssue::UinputPermissionDenied + | InjectionReadinessIssue::UinputUnavailable(_) + ) + }) + } + + pub(crate) fn only_requires_relogin(&self) -> bool { + matches!( + self.issues.as_slice(), + [InjectionReadinessIssue::UinputPermissionDenied] + ) + } + + pub fn issue_lines(&self) -> Vec { + self.issues + .iter() + .map(InjectionReadinessIssue::issue_line) + .collect() + } + + pub fn fix_lines(&self) -> Vec { + let mut lines = Vec::new(); + for issue in &self.issues { + for line in issue.fix_lines() { + if !lines.iter().any(|existing| existing == &line) { + lines.push(line); + } + } + } + lines + } + + pub(crate) fn as_error(&self) -> Option { + if self.is_ready() { + return None; + } + + let details = self + .issues + .iter() + .map(InjectionReadinessIssue::runtime_detail) + .collect::>() + .join("; "); + Some(WhsprError::Injection(format!( + "paste injection is not ready: {details}" + ))) + } + + #[cfg(test)] + pub(crate) fn from_issues(issues: Vec) -> Self { + Self { issues } + } +} + +impl InjectionReadinessIssue { + fn issue_line(&self) -> String { + match self { + Self::MissingWlCopy => "`wl-copy` is not available on PATH.".into(), + Self::MissingUinputDevice => { + format!("{UINPUT_PATH} is missing, so whispers cannot create its virtual keyboard.") + } + Self::UinputPermissionDenied => { + format!("The current user cannot open {UINPUT_PATH}.") + } + Self::UinputUnavailable(detail) => { + format!("{UINPUT_PATH} exists but could not be opened: {detail}.") + } + } + } + + fn fix_lines(&self) -> Vec { + match self { + Self::MissingWlCopy => vec!["Install the `wl-clipboard` package.".into()], + Self::MissingUinputDevice => vec![ + "Run `whispers setup` to configure `/dev/uinput` automatically.".into(), + "Load the `uinput` kernel module: sudo modprobe uinput".into(), + "Persist it across reboots if needed: create `/etc/modules-load.d/whispers-uinput.conf` with `uinput`.".into(), + ], + Self::UinputPermissionDenied => vec![ + "Run `whispers setup` to create a dedicated `uinput` group and `/dev/uinput` rule.".into(), + "Add your user to the `uinput` group and create a `udev` rule for `/dev/uinput`.".into(), + "Log out and back in after changing group membership.".into(), + ], + Self::UinputUnavailable(_) => { + vec![ + "Check that `/dev/uinput` exists and is readable and writable by the current user." + .into(), + ] + } + } + } + + fn runtime_detail(&self) -> String { + match self { + Self::MissingWlCopy => "`wl-copy` was not found; install `wl-clipboard`".into(), + Self::MissingUinputDevice => { + "`/dev/uinput` is missing; load the `uinput` kernel module".into() + } + Self::UinputPermissionDenied => { + "`/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}") + } + } + } +} + +pub fn validate_injection_prerequisites() -> Result<()> { + let report = InjectionReadinessReport::collect(); + if let Some(err) = report.as_error() { + return Err(err); + } + Ok(()) +} + +fn probe_uinput() -> Option { + 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().read(true).write(true).open(path) { + Ok(_) => None, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + Some(InjectionReadinessIssue::UinputPermissionDenied) + } + Err(err) => Some(InjectionReadinessIssue::UinputUnavailable(err.to_string())), + } +} + +fn command_on_path(program: &str) -> bool { + let Some(path) = env::var_os("PATH") else { + return false; + }; + + env::split_paths(&path).any(|dir| { + let candidate = dir.join(program); + match fs::metadata(candidate) { + Ok(metadata) => metadata.is_file() && metadata.permissions().mode() & 0o111 != 0, + Err(_) => false, + } + }) +} diff --git a/src/inject/tests.rs b/src/inject/tests.rs index 921c059..f1bc9d5 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -1,5 +1,8 @@ +use std::os::unix::fs::PermissionsExt; + use crate::error::WhsprError; +use super::preflight::InjectionReadinessIssue; use super::*; #[test] @@ -18,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"); @@ -52,3 +55,81 @@ async fn inject_empty_text_is_noop() { let injector = TextInjector::with_wl_copy_command("/bin/true", &[]); injector.inject("").await.expect("empty text should no-op"); } + +#[test] +fn readiness_report_formats_missing_uinput_guidance() { + let report = + InjectionReadinessReport::from_issues(vec![InjectionReadinessIssue::MissingUinputDevice]); + assert!(!report.is_ready()); + assert!( + report + .issue_lines() + .iter() + .any(|line| line.contains("/dev/uinput")) + ); + assert!( + report + .fix_lines() + .iter() + .any(|line| line.contains("sudo modprobe uinput")) + ); + + let err = report.as_error().expect("missing uinput should fail"); + match err { + WhsprError::Injection(msg) => { + assert!( + msg.contains("paste injection is not ready"), + "unexpected: {msg}" + ); + assert!(msg.contains("uinput` kernel module"), "unexpected: {msg}"); + } + other => panic!("unexpected error variant: {other:?}"), + } +} + +#[test] +fn readiness_report_formats_permission_guidance() { + let report = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + assert!(report.has_uinput_issue()); + assert!( + report + .fix_lines() + .iter() + .any(|line| line.contains("`uinput` group")) + ); +} + +#[test] +fn readiness_report_only_requires_relogin_when_permission_is_last_blocker() { + let relogin_only = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + ]); + assert!(relogin_only.only_requires_relogin()); + + let additional_steps = InjectionReadinessReport::from_issues(vec![ + InjectionReadinessIssue::UinputPermissionDenied, + InjectionReadinessIssue::MissingWlCopy, + ]); + 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/main.rs b/src/main.rs index f8f05f2..170aec5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use whispers::config::Config; use whispers::error::Result; use whispers::rewrite_protocol::RewriteSurfaceKind; use whispers::{ - agentic_rewrite, app, asr, asr_model, audio, cloud, completions, file_audio, model, + agentic_rewrite, app, asr, asr_model, audio, cloud, completions, file_audio, inject, model, personalization, postprocess, rewrite_model, runtime_support, setup, }; @@ -104,6 +104,7 @@ async fn run_default(cli: &Cli) -> Result<()> { // Load config let config = Config::load(cli.config.as_deref())?; asr::validation::validate_transcription_config(&config)?; + inject::validate_injection_prerequisites()?; tracing::debug!("config loaded: {config:?}"); app::run(config, pid_lock).await diff --git a/src/setup.rs b/src/setup.rs index 87aa2f8..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(); @@ -111,6 +117,7 @@ pub async fn run_setup(config_path_override: Option<&Path>) -> Result<()> { apply::apply_setup_config(&ui, &config_path, config_path_override, &selections)?; side_effects::maybe_create_agentic_starter_files(&ui, &config_path, &selections)?; side_effects::cleanup_stale_asr_workers(&ui, &config_path)?; + let injection_setup = side_effects::maybe_setup_injection_access(&ui)?; if let Some(rewrite_model) = selections.rewrite_model { ui.print_ok(format!( @@ -125,7 +132,10 @@ pub async fn run_setup(config_path_override: Option<&Path>) -> Result<()> { ui.blank(); report::print_setup_summary(&ui, &selections); ui.blank(); - report::print_setup_complete(&ui); + let injection_readiness = crate::inject::InjectionReadinessReport::collect(); + report::print_injection_readiness(&ui, &injection_readiness, &injection_setup); + ui.blank(); + report::print_setup_complete(&ui, &injection_readiness, &injection_setup); Ok(()) } diff --git a/src/setup/report.rs b/src/setup/report.rs index 8d042d0..7f0823c 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -1,7 +1,8 @@ use crate::config::TranscriptionBackend; +use crate::inject::InjectionReadinessReport; use crate::ui::SetupUi; -use super::SetupSelections; +use super::{SetupSelections, side_effects::InjectionSetupOutcome}; pub(super) fn print_setup_intro(ui: &SetupUi) { ui.print_subtle( @@ -69,10 +70,85 @@ pub(super) fn print_setup_summary(ui: &SetupUi, selections: &SetupSelections) { } } -pub(super) fn print_setup_complete(ui: &SetupUi) { +pub(super) fn print_injection_readiness( + ui: &SetupUi, + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) { + ui.print_section("Paste injection"); + + if readiness.is_ready() { + ui.print_ok("Clipboard and virtual keyboard access look ready."); + return; + } + + ui.print_warn("Paste injection still needs one-time system setup."); + for line in readiness.issue_lines() { + println!(" - {line}"); + } + + if let Some(message) = injection_readiness_info_message(readiness, setup) { + ui.print_info(message); + } + + 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, + setup: &InjectionSetupOutcome, +) { ui.print_header("Setup complete"); - println!("You can now use whispers."); + println!( + "{}", + setup_complete_message( + readiness.is_ready(), + setup.changed_groups, + setup.can_finish_with_relogin_only(readiness.only_requires_relogin()), + ) + ); ui.print_section("Example keybind"); ui.print_subtle("Bind it to a key in your compositor, e.g. for Hyprland:"); println!(" bind = SUPER ALT, D, exec, whispers"); } + +pub(super) fn setup_complete_message( + is_ready: bool, + changed_groups: bool, + can_finish_with_relogin_only: bool, +) -> &'static str { + if is_ready { + "You can now use whispers." + } 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." + } else { + "Finish the paste injection steps above, then use whispers." + } +} diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 67590cc..efbdd8e 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,4 +1,7 @@ +use std::ffi::{CStr, CString}; +use std::io::Write; use std::path::Path; +use std::process::{Command, Stdio}; use crate::config::{self, TranscriptionBackend}; use crate::error::Result; @@ -6,6 +9,59 @@ use crate::ui::SetupUi; use super::SetupSelections; +const UINPUT_GROUP: &str = "uinput"; +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( ui: &SetupUi, asr_model: &'static crate::asr_model::AsrModelInfo, @@ -86,8 +142,346 @@ pub(super) fn maybe_prewarm_experimental_nemo( Ok(()) } +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()); + } + + ui.blank(); + ui.print_section("System setup"); + ui.print_warn("`/dev/uinput` is not ready, so paste injection will fail."); + for line in readiness.issue_lines() { + println!(" - {line}"); + } + + if !ui.confirm("Set up `/dev/uinput` access now? This uses sudo.", true)? { + return Ok(InjectionSetupOutcome::default()); + } + + let mut outcome = InjectionSetupOutcome::default(); + if let Err(err) = ensure_uinput_module_loaded(ui) { + ui.print_warn(format!("Failed to load the `uinput` module: {err}")); + } + if let Err(err) = install_root_file(ui, MODULES_LOAD_PATH, MODULES_LOAD_CONTENT) { + ui.print_warn(format!("Failed to persist the `uinput` module load: {err}")); + } + match install_root_file(ui, UDEV_RULE_PATH, UDEV_RULE_CONTENT) { + Ok(()) => 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!( + "Failed to ensure the `{UINPUT_GROUP}` group exists: {err}" + )); + } + if let Some(warning) = record_group_membership_change_result( + &mut outcome, + UINPUT_GROUP, + add_user_to_group(ui, UINPUT_GROUP), + ) { + ui.print_warn(warning); + } + if outcome.should_reload_udev() { + match reload_udev(ui) { + Ok(()) => 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 let Some(message) = outcome.setup_group_change_message() { + ui.print_info(message); + } + + Ok(outcome) +} + +pub(super) fn record_group_membership_change_result( + outcome: &mut InjectionSetupOutcome, + group: &str, + result: Result, +) -> Option { + match result { + Ok(true) => { + outcome.changed_groups = true; + outcome.group_membership_ready = true; + None + } + Ok(false) => { + outcome.group_membership_ready = true; + None + } + Err(err) => Some(format!( + "Failed to add the current user to the `{group}` group: {err}" + )), + } +} + fn asr_model_prewarm(config: &config::Config) -> Result<()> { let prepared = crate::asr::prepare::prepare_transcriber(config)?; crate::asr::prepare::prewarm_transcriber(&prepared, "setup"); Ok(()) } + +fn ensure_uinput_module_loaded(ui: &SetupUi) -> Result<()> { + ui.print_info("Loading the `uinput` kernel module..."); + run_sudo(&["modprobe", "uinput"]) +} + +fn reload_udev(ui: &SetupUi) -> Result<()> { + ui.print_info("Reloading `udev` rules for `/dev/uinput`..."); + run_sudo(&["udevadm", "control", "--reload"])?; + run_sudo(UDEV_TRIGGER_ARGS) +} + +fn add_user_to_group(ui: &SetupUi, group: &str) -> Result { + let username = current_username()?; + if current_user_in_group(&username, group)? { + ui.print_info(format!( + "User is already configured for the `{group}` group." + )); + return Ok(false); + } + + ui.print_info(format!("Adding `{username}` to the `{group}` group...")); + run_sudo(&["usermod", "-aG", group, &username])?; + Ok(true) +} + +fn ensure_group_exists(ui: &SetupUi, group: &str) -> Result<()> { + if group_exists(group)? { + ui.print_info(format!("Group `{group}` already exists.")); + return Ok(()); + } + + ui.print_info(format!( + "Creating dedicated `{group}` group for `/dev/uinput`..." + )); + run_sudo(&["groupadd", "--system", group]) +} + +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" + )) + })?; + + 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 { + let output = Command::new("id") + .args(["-nG", username]) + .output() + .map_err(|err| { + crate::error::WhsprError::Config(format!("failed to inspect groups: {err}")) + })?; + if !output.status.success() { + return Err(crate::error::WhsprError::Config(format!( + "`id -nG {username}` exited with {}", + output.status + ))); + } + let groups = String::from_utf8_lossy(&output.stdout); + Ok(groups.split_whitespace().any(|entry| entry == group)) +} + +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(), + )); + } + + 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, + }; + + 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}: {}", + std::io::Error::from_raw_os_error(status) + ))); + } +} + +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}`" + ))); + }; + run_sudo(&["mkdir", "-p", parent])?; + run_sudo_with_input(&["tee", target], contents)?; + run_sudo(&["chmod", "0644", target])?; + ui.print_info(format!("Installed `{target}`.")); + 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)) + })?; + if !status.success() { + return Err(crate::error::WhsprError::Config(format!( + "`sudo {}` exited with {status}", + args.join(" ") + ))); + } + Ok(()) +} + +fn run_sudo_with_input(args: &[&str], input: &str) -> Result<()> { + let mut child = Command::new("sudo") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .map_err(|err| { + crate::error::WhsprError::Config(format!("failed to run sudo {:?}: {err}", args)) + })?; + + let mut stdin = child.stdin.take().ok_or_else(|| { + crate::error::WhsprError::Config(format!("failed to open stdin for sudo {:?}", args)) + })?; + stdin.write_all(input.as_bytes()).map_err(|err| { + crate::error::WhsprError::Config(format!( + "failed to write stdin for sudo {:?}: {err}", + args + )) + })?; + drop(stdin); + + let status = child.wait().map_err(|err| { + crate::error::WhsprError::Config(format!("failed to wait for sudo {:?}: {err}", args)) + })?; + if !status.success() { + return Err(crate::error::WhsprError::Config(format!( + "`sudo {}` exited with {status}", + args.join(" ") + ))); + } + Ok(()) +} diff --git a/src/setup/tests.rs b/src/setup/tests.rs index e688392..2e25ed4 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -1,6 +1,8 @@ use crate::config::Config; +use crate::error::WhsprError; +use crate::inject::{InjectionReadinessIssue, InjectionReadinessReport}; -use super::{CloudSetup, apply}; +use super::{CloudSetup, apply, report, side_effects}; use crate::config::{self, TranscriptionBackend, TranscriptionFallback}; #[cfg(not(feature = "local-rewrite"))] @@ -50,3 +52,273 @@ fn runtime_selection_disables_local_rewrite_fallback_when_build_lacks_local_rewr assert_eq!(config.rewrite.backend, RewriteBackend::Cloud); assert_eq!(config.rewrite.fallback, RewriteFallback::None); } + +#[test] +fn group_membership_failures_become_warnings_without_marking_success() { + let mut outcome = side_effects::InjectionSetupOutcome::default(); + let warning = side_effects::record_group_membership_change_result( + &mut outcome, + "uinput", + Err(WhsprError::Config("group add failed".into())), + ) + .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(); + let warning = + side_effects::record_group_membership_change_result(&mut outcome, "uinput", Ok(true)); + + 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] +fn setup_complete_message_stays_aligned_with_remaining_steps() { + assert_eq!( + report::setup_complete_message(false, true, true), + "Log out and back in, then use 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, 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." + ); + assert_eq!( + report::setup_complete_message(true, false, false), + "You can now use whispers." + ); +}