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."
+ );
+}