From cbffb39416c4a84916ddfac5f2c10bb74bde0a67 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 13:20:55 +0200 Subject: [PATCH 01/14] fix: preflight paste injection requirements --- src/inject/mod.rs | 2 + src/inject/preflight.rs | 179 ++++++++++++++++++++++++++++++++++++++ src/inject/tests.rs | 46 ++++++++++ src/main.rs | 3 +- src/setup.rs | 6 +- src/setup/report.rs | 45 +++++++++- src/setup/side_effects.rs | 158 +++++++++++++++++++++++++++++++++ 7 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 src/inject/preflight.rs diff --git a/src/inject/mod.rs b/src/inject/mod.rs index 0df955b..97b19e1 100644 --- a/src/inject/mod.rs +++ b/src/inject/mod.rs @@ -1,10 +1,12 @@ mod clipboard; mod keyboard; +mod preflight; #[cfg(test)] mod tests; use crate::error::{Result, WhsprError}; +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..a85ebf7 --- /dev/null +++ b/src/inject/preflight.rs @@ -0,0 +1,179 @@ +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 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![ + "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![ + "Add your user to the `input` 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 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 writable by the current user; add a `udev` rule, add your user to the `input` group, 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 { + let path = Path::new(UINPUT_PATH); + if !path.exists() { + return Some(InjectionReadinessIssue::MissingUinputDevice); + } + + match OpenOptions::new().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..ffa214f 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -1,5 +1,6 @@ use crate::error::WhsprError; +use super::preflight::InjectionReadinessIssue; use super::*; #[test] @@ -52,3 +53,48 @@ 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("`input` group")) + ); +} 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..3d7028d 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -111,6 +111,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 +126,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..202911c 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,9 +70,47 @@ 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 setup.changed_groups { + ui.print_info( + "If you were just added to the `input` group, log out and back in before testing.", + ); + } + + for line in readiness.fix_lines() { + println!(" - {line}"); + } +} + +pub(super) fn print_setup_complete( + ui: &SetupUi, + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) { ui.print_header("Setup complete"); - println!("You can now use whispers."); + if readiness.is_ready() { + println!("You can now use whispers."); + } else if setup.changed_groups { + println!("Log out and back in, then use whispers."); + } else { + println!("Finish the paste injection steps above, then use whispers."); + } 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"); diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 67590cc..dc76abd 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,3 +1,6 @@ +use std::path::PathBuf; +use std::process::Command; + use std::path::Path; use crate::config::{self, TranscriptionBackend}; @@ -6,6 +9,16 @@ use crate::ui::SetupUi; use super::SetupSelections; +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=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"\n"; +const MODULES_LOAD_CONTENT: &str = "uinput\n"; + +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct InjectionSetupOutcome { + pub changed_groups: bool, +} + pub(super) async fn download_asr_model( ui: &SetupUi, asr_model: &'static crate::asr_model::AsrModelInfo, @@ -86,8 +99,153 @@ pub(super) fn maybe_prewarm_experimental_nemo( Ok(()) } +pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result { + 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}")); + } + if let Err(err) = install_root_file(ui, UDEV_RULE_PATH, UDEV_RULE_CONTENT) { + ui.print_warn(format!( + "Failed to install the `/dev/uinput` udev rule: {err}" + )); + } + if add_user_to_group(ui, "input")? { + outcome.changed_groups = true; + } + if let Err(err) = reload_udev(ui) { + ui.print_warn(format!( + "Failed to reload `udev` after updating `/dev/uinput`: {err}" + )); + } + + if outcome.changed_groups { + ui.print_info("Group membership changed. Log out and back in before testing dictation."); + } + + Ok(outcome) +} + 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(&[ + "udevadm", + "trigger", + "--subsystem-match=misc", + "--sysname-match=uinput", + ]) +} + +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 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 { + if let Some(name) = std::env::var_os("SUDO_USER").or_else(|| std::env::var_os("USER")) { + let username = name.to_string_lossy().trim().to_string(); + if !username.is_empty() { + return Ok(username); + } + } + + let output = Command::new("id").arg("-un").output().map_err(|err| { + crate::error::WhsprError::Config(format!("failed to determine username: {err}")) + })?; + if !output.status.success() { + return Err(crate::error::WhsprError::Config(format!( + "`id -un` exited with {}", + output.status + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { + let temp_path = temp_file_path(target); + std::fs::write(&temp_path, contents)?; + let temp_path_str = temp_path.to_string_lossy().to_string(); + let result = run_sudo(&["install", "-Dm644", &temp_path_str, target]); + let _ = std::fs::remove_file(&temp_path); + result?; + ui.print_info(format!("Installed `{target}`.")); + Ok(()) +} + +fn temp_file_path(target: &str) -> PathBuf { + let basename = Path::new(target) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("whispers-temp"); + std::env::temp_dir().join(format!("whispers-{}-{basename}", std::process::id())) +} + +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(()) +} From ae27afc8f338745c6a1292895a789edd5286fd67 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 13:32:20 +0200 Subject: [PATCH 02/14] fix: harden uinput setup permissions --- README.md | 6 +- src/inject/preflight.rs | 6 +- src/inject/tests.rs | 2 +- src/setup/report.rs | 2 +- src/setup/side_effects.rs | 133 +++++++++++++++++++++++++++++--------- 5 files changed, 109 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e6e99c7..470a994 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,7 @@ cargo install --git https://github.com/OneNoted/whispers --features local-rewrit - 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" -``` +If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedicated `uinput` group and `udev` rule for you. ## Quick start diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs index a85ebf7..7fe730c 100644 --- a/src/inject/preflight.rs +++ b/src/inject/preflight.rs @@ -112,11 +112,13 @@ impl InjectionReadinessIssue { 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![ - "Add your user to the `input` group and create a `udev` rule for `/dev/uinput`.".into(), + "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(_) => { @@ -132,7 +134,7 @@ impl InjectionReadinessIssue { "`/dev/uinput` is missing; load the `uinput` kernel module".into() } Self::UinputPermissionDenied => { - "`/dev/uinput` is present but not writable by the current user; add a `udev` rule, add your user to the `input` group, then log out and back in".into() + "`/dev/uinput` is present but not writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() } Self::UinputUnavailable(detail) => { format!("`/dev/uinput` could not be opened: {detail}") diff --git a/src/inject/tests.rs b/src/inject/tests.rs index ffa214f..b52089c 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -95,6 +95,6 @@ fn readiness_report_formats_permission_guidance() { report .fix_lines() .iter() - .any(|line| line.contains("`input` group")) + .any(|line| line.contains("`uinput` group")) ); } diff --git a/src/setup/report.rs b/src/setup/report.rs index 202911c..e7060be 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -89,7 +89,7 @@ pub(super) fn print_injection_readiness( if setup.changed_groups { ui.print_info( - "If you were just added to the `input` group, log out and back in before testing.", + "If you were just added to the `uinput` group, log out and back in before testing.", ); } diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index dc76abd..4dd93d9 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; -use std::process::Command; - +use std::ffi::CStr; +use std::io::Write; use std::path::Path; +use std::process::{Command, Stdio}; use crate::config::{self, TranscriptionBackend}; use crate::error::Result; @@ -9,9 +9,10 @@ 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=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"\n"; +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"; #[derive(Debug, Clone, Copy, Default)] @@ -128,7 +129,12 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result Result { 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]) +} + +fn group_exists(group: &str) -> Result { + let group_file = std::fs::read_to_string("/etc/group").map_err(|err| { + crate::error::WhsprError::Config(format!("failed to read /etc/group: {err}")) + })?; + + Ok(group_file + .lines() + .filter_map(|line| line.split(':').next()) + .any(|entry| entry == group)) +} + fn current_user_in_group(username: &str, group: &str) -> Result { let output = Command::new("id") .args(["-nG", username]) @@ -198,45 +227,54 @@ fn current_user_in_group(username: &str, group: &str) -> Result { } fn current_username() -> Result { - if let Some(name) = std::env::var_os("SUDO_USER").or_else(|| std::env::var_os("USER")) { - let username = name.to_string_lossy().trim().to_string(); - if !username.is_empty() { - return Ok(username); - } + let uid = unsafe { libc::geteuid() }; + if uid == 0 { + return Err(crate::error::WhsprError::Config( + "run `whispers setup` as your normal user, not as root".into(), + )); } - let output = Command::new("id").arg("-un").output().map_err(|err| { - crate::error::WhsprError::Config(format!("failed to determine username: {err}")) - })?; - if !output.status.success() { + let buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { + value if value > 0 => value as usize, + _ => 1024, + }; + let mut buffer = vec![0u8; buffer_len]; + let mut passwd = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + if status != 0 || result.is_null() { return Err(crate::error::WhsprError::Config(format!( - "`id -un` exited with {}", - output.status + "failed to resolve current username for uid {uid}" ))); } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + let passwd = unsafe { passwd.assume_init() }; + let name = unsafe { CStr::from_ptr(passwd.pw_name) }; + Ok(name.to_string_lossy().into_owned()) } fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { - let temp_path = temp_file_path(target); - std::fs::write(&temp_path, contents)?; - let temp_path_str = temp_path.to_string_lossy().to_string(); - let result = run_sudo(&["install", "-Dm644", &temp_path_str, target]); - let _ = std::fs::remove_file(&temp_path); - result?; + 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 temp_file_path(target: &str) -> PathBuf { - let basename = Path::new(target) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("whispers-temp"); - std::env::temp_dir().join(format!("whispers-{}-{basename}", std::process::id())) -} - 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)) @@ -249,3 +287,36 @@ fn run_sudo(args: &[&str]) -> Result<()> { } 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(()) +} From b51db87f3b9eae0e9eedb2ee3e6e04cf64542170 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 13:45:41 +0200 Subject: [PATCH 03/14] fix: keep setup guidance accurate after partial uinput failures The uinput setup path now treats user-group changes like the other best-effort side effects, so setup can keep reporting recovery steps even if groupadd, usermod, or sudo/root user detection fails after earlier mutations. The completion banner also only tells users to relog and start using whispers when a fresh login is the last remaining injection prerequisite. Constraint: Published PR review comments should be addressed as follow-up commits, not history rewrites Rejected: Keep bubbling add_user_to_group errors | aborts after partial system mutations and hides recovery guidance Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep final setup status aligned with the post-setup readiness report; do not report completion when non-relogin prerequisites still fail Tested: cargo test --quiet Not-tested: interactive sudo-driven whispers setup on a live system --- src/inject/preflight.rs | 7 ++++++ src/inject/tests.rs | 14 ++++++++++++ src/setup/report.rs | 31 +++++++++++++++++++------ src/setup/side_effects.rs | 25 ++++++++++++++++++-- src/setup/tests.rs | 48 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs index 7fe730c..f182ab5 100644 --- a/src/inject/preflight.rs +++ b/src/inject/preflight.rs @@ -51,6 +51,13 @@ impl InjectionReadinessReport { }) } + pub(crate) fn only_requires_relogin(&self) -> bool { + matches!( + self.issues.as_slice(), + [InjectionReadinessIssue::UinputPermissionDenied] + ) + } + pub fn issue_lines(&self) -> Vec { self.issues .iter() diff --git a/src/inject/tests.rs b/src/inject/tests.rs index b52089c..a655058 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -98,3 +98,17 @@ fn readiness_report_formats_permission_guidance() { .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()); +} diff --git a/src/setup/report.rs b/src/setup/report.rs index e7060be..d6a1dfd 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -104,14 +104,31 @@ pub(super) fn print_setup_complete( setup: &InjectionSetupOutcome, ) { ui.print_header("Setup complete"); - if readiness.is_ready() { - println!("You can now use whispers."); - } else if setup.changed_groups { - println!("Log out and back in, then use whispers."); - } else { - println!("Finish the paste injection steps above, then use whispers."); - } + println!( + "{}", + setup_complete_message( + readiness.is_ready(), + setup.changed_groups, + 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, + only_requires_relogin: bool, +) -> &'static str { + if is_ready { + "You can now use whispers." + } else if changed_groups && only_requires_relogin { + "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 4dd93d9..ca58900 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -134,8 +134,12 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result Result, +) -> Option { + match result { + Ok(true) => { + outcome.changed_groups = true; + None + } + Ok(false) => 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"); diff --git a/src/setup/tests.rs b/src/setup/tests.rs index e688392..e167206 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -1,6 +1,7 @@ use crate::config::Config; +use crate::error::WhsprError; -use super::{CloudSetup, apply}; +use super::{CloudSetup, apply, report, side_effects}; use crate::config::{self, TranscriptionBackend, TranscriptionFallback}; #[cfg(not(feature = "local-rewrite"))] @@ -50,3 +51,48 @@ 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!(warning.contains("Failed to add the current user")); + assert!(warning.contains("group add failed")); +} + +#[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); +} + +#[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, 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." + ); +} From 5b9da1feabf57313d8d95414d42b9cfb1853583b Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 14:22:22 +0200 Subject: [PATCH 04/14] docs: keep the landing page accurate and skimmable Move install, CLI, and troubleshooting detail out of the README so the front page stays focused while the deeper docs remain aligned with the current command surface and distribution paths. Constraint: README should stay concise without hiding operational guidance Constraint: Documentation claims must match the current CLI, release workflow, and package metadata Rejected: Keep all reference material in README | harder to scan and easier to let drift Confidence: high Scope-risk: narrow Reversibility: clean Directive: Re-check README install claims against release assets, AUR packages, and CLI help before future doc refreshes Tested: cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test; markdown local link check; verified GitHub release v0.2.2 and AUR package pages for whispers{-bin,-git,-cuda-bin,-cuda-git} Not-tested: GitHub web markdown rendering --- README.md | 124 ++++++---------------------------------- docs/cli.md | 100 ++++++++++++++++++++++++++++++++ docs/install.md | 108 ++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 65 +++++++++++++++++++++ 4 files changed, 289 insertions(+), 108 deletions(-) create mode 100644 docs/cli.md create mode 100644 docs/install.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index 470a994..cb6793e 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,14 @@ · Quick start · - Commands + Docs · Troubleshooting · Releases

-`whispers` keeps the default dictation path local, with optional cloud ASR and rewrite backends when you want them. The normal loop is simple: start recording with a keybinding, stop recording, and paste the transcript directly into the focused app. +`whispers` keeps the default dictation path local, with optional cloud ASR and rewrite backends when you want them. The normal loop is simple: bind `whispers` to a key, press once to start recording, press again to stop, transcribe, and paste into the focused Wayland app. ## What it does @@ -34,66 +34,22 @@ ## Install -### Arch Linux (`paru`) - -#### CUDA-enabled - -```sh -# prebuilt GitHub release bundle with CUDA support -paru -S whispers-cuda-bin - -# latest main branch build with CUDA support -paru -S whispers-cuda-git -``` - -Use these when you want the CUDA-enabled local path from the AUR. +For the full package matrix, prerequisites, and post-install notes, see [docs/install.md](docs/install.md). -#### Portable / non-CUDA +### Arch Linux (`paru`) ```sh -# prebuilt GitHub release bundle paru -S whispers-bin - -# latest main branch build -paru -S whispers-git +# or: whispers-git / whispers-cuda-bin / whispers-cuda-git ``` -- `whispers-cuda-bin` installs the published Linux x86_64 CUDA bundle. -- `whispers-cuda-git` builds the latest `main` branch with `cuda,local-rewrite,osd`. -- `whispers-bin` installs the published portable non-CUDA Linux x86_64 bundle. -- `whispers-git` builds the latest `main` branch with the portable `local-rewrite,osd` feature set. - ### Cargo ```sh -# crates.io with the default OSD-enabled install cargo install whispers - -# add local rewrite support -cargo install whispers --features local-rewrite - -# add CUDA + local rewrite -cargo install whispers --features cuda,local-rewrite - -# no OSD -cargo install whispers --no-default-features ``` -If you want the latest GitHub version instead of crates.io: - -```sh -cargo install --git https://github.com/OneNoted/whispers --features local-rewrite -``` - -## Requirements - -- Linux with Wayland -- `wl-copy` -- access to `/dev/uinput` -- Rust 1.85+ -- CUDA toolkit if you enable the `cuda` feature - -If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedicated `uinput` group and `udev` rule for you. +`cargo install whispers` follows crates.io releases. The AUR `*-bin` packages follow published GitHub release bundles, and `*-git` packages track the repository `main` branch. If you need rewrite or CUDA features, or want install details before choosing a package, use [docs/install.md](docs/install.md). ## Quick start @@ -101,7 +57,7 @@ If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedic # generate config and download a model whispers setup -# one-shot dictation +# start dictation (run again to stop, transcribe, and paste) whispers ``` @@ -111,11 +67,7 @@ Default config path: ~/.config/whispers/config.toml ``` -Canonical example config: - -- [config.example.toml](config.example.toml) - -### Keybinding +Example compositor bindings: Hyprland: @@ -129,70 +81,26 @@ Sway: bindsym $mod+Alt+d exec whispers ``` -## Commands - -```sh -# setup -whispers setup - -# one-shot dictation -whispers -whispers transcribe audio.wav - -# ASR models -whispers asr-model list -whispers asr-model download large-v3-turbo -whispers asr-model select large-v3-turbo - -# rewrite models -whispers rewrite-model list -whispers rewrite-model download qwen-3.5-4b-q4_k_m -whispers rewrite-model select qwen-3.5-4b-q4_k_m - -# personalization -whispers dictionary add "wisper flow" "Wispr Flow" -whispers snippets add signature "Best regards,\nNotes" - -# cloud -whispers cloud check - -# shell completions -whispers completions zsh -``` - -## Notes +## Docs -- Local ASR is the default path. -- Local rewrite is enabled when you install with `--features local-rewrite` or use the current AUR packages. -- `whispers` installs the helper rewrite worker for you when that feature is enabled. -- Shell completions are printed to `stdout`. +- [Installation guide](docs/install.md) — package choices, prerequisites, config path, and feature notes. +- [CLI guide](docs/cli.md) — command groups, examples, and newer rewrite-policy commands. +- [Troubleshooting](docs/troubleshooting.md) — `wl-copy`, `/dev/uinput`, cloud checks, and hang diagnostics. +- [config.example.toml](config.example.toml) — the canonical config template. ## Troubleshooting -If the main `whispers` process ever gets stuck after playback when using local `whisper_cpp`, enable the built-in hang diagnostics for the next repro: +If `/dev/uinput` is blocked, run `whispers setup` and let it configure the dedicated `uinput` group and `udev` rule for you. If the main dictation process hangs around local `whisper_cpp` transcription, enable hang diagnostics for the next repro: ```sh WHISPERS_HANG_DEBUG=1 whispers ``` -When that mode is enabled, `whispers` writes runtime status and hang bundles under `${XDG_RUNTIME_DIR:-/tmp}/whispers/`: - -- `main-status.json` shows the current dictation stage and recent stage metadata. -- `hang---.log` is emitted if `whisper_cpp` spends too long in model load or transcription. - -Those bundles include the current status snapshot plus best-effort stack and open-file diagnostics. If the hang reproduces, capture the newest `hang-*.log` file along with `main-status.json`. +For the full troubleshooting guide, including the emitted `main-status.json` and `hang-*.log` files, see [docs/troubleshooting.md](docs/troubleshooting.md). ## Releases -Tagged releases publish a Linux x86_64 bundle with: - -- a portable `whispers--x86_64-unknown-linux-gnu.tar.gz` -- a CUDA-enabled `whispers-cuda--x86_64-unknown-linux-gnu.tar.gz` -- `whispers`, `whispers-osd`, and `whispers-rewrite-worker` -- Bash, Zsh, and Fish completions -- `README.md`, `config.example.toml`, `LICENSE`, and `NOTICE` - -Those bundles are what the `whispers-bin` and `whispers-cuda-bin` AUR packages install. +Tagged releases publish portable and CUDA-enabled Linux x86_64 bundles. The AUR `whispers-bin` and `whispers-cuda-bin` packages install those published release artifacts. ## License diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..954e32b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,100 @@ +# CLI guide + +For the full generated help, run `whispers --help` or `whispers --help`. + +## Top-level commands + +| Command | Purpose | +| --- | --- | +| `whispers` | Start or stop the default dictation loop | +| `whispers setup` | Guided setup for local, cloud, and experimental dictation paths | +| `whispers transcribe ` | Transcribe an audio file to stdout or a file | +| `whispers model` | Legacy `whisper_cpp` model commands | +| `whispers asr-model` | Manage ASR models across recommended and experimental backends | +| `whispers rewrite-model` | Manage local rewrite models | +| `whispers dictionary` | Manage deterministic replacements | +| `whispers app-rule` | Manage app-aware rewrite policy rules | +| `whispers glossary` | Manage technical glossary entries | +| `whispers cloud check` | Validate cloud config and connectivity | +| `whispers snippets` | Manage spoken snippets | +| `whispers rewrite-instructions-path` | Print the custom rewrite instructions file path | +| `whispers completions [shell]` | Print shell completions to stdout | + +## Common flows + +### Setup and dictation + +```sh +whispers setup +whispers +``` + +### File transcription + +```sh +whispers transcribe audio.wav +whispers transcribe meeting.m4a --output transcript.txt +whispers transcribe audio.wav --raw +``` + +### ASR model management + +```sh +whispers asr-model list +whispers asr-model download large-v3-turbo +whispers asr-model select large-v3-turbo +``` + +`whispers model` still exists for legacy `whisper_cpp` flows, but `whispers asr-model` is the preferred interface. + +### Rewrite model management + +```sh +whispers rewrite-model list +whispers rewrite-model download qwen-3.5-4b-q4_k_m +whispers rewrite-model select qwen-3.5-4b-q4_k_m +``` + +### Personalization + +```sh +whispers dictionary add "wisper flow" "Wispr Flow" +whispers snippets add signature "Best regards,\nNotes" +``` + +### Rewrite policy files + +```sh +whispers app-rule path +whispers glossary path +whispers rewrite-instructions-path +``` + +### App-aware rewrite rules + +```sh +whispers app-rule list +whispers app-rule add jira-browser \ + "Prefer issue IDs and preserve markdown punctuation" \ + --surface-kind browser \ + --browser-domain-contains atlassian.net +whispers app-rule remove jira-browser +``` + +### Technical glossary entries + +```sh +whispers glossary list +whispers glossary add serde_json \ + --alias "surdy json" \ + --alias "serdy json" \ + --surface-kind editor +whispers glossary remove serde_json +``` + +### Cloud and shell integration + +```sh +whispers cloud check +whispers completions zsh +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..9e7dfc6 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,108 @@ +# Install + +`whispers` targets Linux Wayland desktops and supports a few different installation paths depending on whether you want stable release bundles, the latest `main` branch, local rewrite, or CUDA. + +## Requirements + +- Linux with Wayland +- `wl-copy` (usually provided by `wl-clipboard`) +- Access to `/dev/uinput` for paste injection +- Rust 1.85+ for Cargo installs and source builds +- CUDA toolkit only when you build with the `cuda` feature yourself + +If `/dev/uinput` is not ready, run `whispers setup`. It can configure the dedicated `uinput` group and matching `udev` rule automatically. + +## Arch Linux (`paru`) + +### Portable / non-CUDA + +```sh +# published GitHub release bundle +paru -S whispers-bin + +# latest main branch build +paru -S whispers-git +``` + +### CUDA-enabled + +```sh +# published GitHub release bundle with CUDA support +paru -S whispers-cuda-bin + +# latest main branch build with CUDA support +paru -S whispers-cuda-git +``` + +### Package matrix + +| Package | Source | Feature set | +| --- | --- | --- | +| `whispers-bin` | Published GitHub release bundle | `local-rewrite,osd` | +| `whispers-git` | Latest `main` branch build | `local-rewrite,osd` | +| `whispers-cuda-bin` | Published GitHub CUDA release bundle | `cuda,local-rewrite,osd` | +| `whispers-cuda-git` | Latest `main` branch build with CUDA | `cuda,local-rewrite,osd` | + +## Cargo + +### crates.io + +```sh +# default install (OSD enabled) +cargo install whispers + +# add local rewrite support +cargo install whispers --features local-rewrite + +# add CUDA + local rewrite +cargo install whispers --features cuda,local-rewrite + +# no OSD +cargo install whispers --no-default-features +``` + +### GitHub source install + +If you want the current repository version instead of the latest crates.io publish: + +```sh +cargo install --git https://github.com/OneNoted/whispers --features local-rewrite +``` + +## After install + +Generate a config, download an ASR model, and walk through optional rewrite/cloud setup: + +```sh +whispers setup +``` + +Default config path: + +```text +~/.config/whispers/config.toml +``` + +Canonical example config: + +- [config.example.toml](../config.example.toml) + +## Keybinding examples + +Hyprland: + +```conf +bind = SUPER ALT, D, exec, whispers +``` + +Sway: + +```conf +bindsym $mod+Alt+d exec whispers +``` + +The first invocation starts recording. The next invocation stops recording, transcribes, and pastes into the currently focused app. + +## Release bundles + +Tagged releases publish portable and CUDA-enabled Linux x86_64 tarballs. The AUR `*-bin` packages install those published release artifacts rather than building from source on your machine. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..799f3af --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,65 @@ +# Troubleshooting + +## Start with setup + +If local dictation is not behaving correctly, rerun the guided setup first: + +```sh +whispers setup +``` + +That flow validates the current config, offers model downloads, and can fix `/dev/uinput` access for paste injection. + +## `wl-copy` is missing + +`whispers` uses `wl-copy` for clipboard injection. Install `wl-clipboard` (or otherwise make `wl-copy` available on `PATH`) and try again. + +## `/dev/uinput` is missing or not writable + +Paste injection depends on `/dev/uinput`. + +Recommended path: + +```sh +whispers setup +``` + +If you handle it manually instead, the fallback guidance is: + +- Load the kernel module: `sudo modprobe uinput` +- Persist it across reboots if needed: create `/etc/modules-load.d/whispers-uinput.conf` with `uinput` +- Create a dedicated `uinput` group and a `udev` rule for `/dev/uinput` +- Log out and back in after group membership changes + +## Cloud checks + +If cloud ASR or rewrite is configured but not working, validate the current provider, credentials, and reachability: + +```sh +whispers cloud check +``` + +## Inspect rewrite resource paths + +These helpers are useful when you want to confirm which runtime files the current config points at: + +```sh +whispers app-rule path +whispers glossary path +whispers rewrite-instructions-path +``` + +## Local `whisper_cpp` hang diagnostics + +If the main `whispers` process ever gets stuck after playback when using local `whisper_cpp`, enable the built-in hang diagnostics for the next repro: + +```sh +WHISPERS_HANG_DEBUG=1 whispers +``` + +When that mode is enabled, `whispers` writes runtime status and hang bundles under `${XDG_RUNTIME_DIR:-/tmp}/whispers/`: + +- `main-status.json` shows the current dictation stage and recent stage metadata. +- `hang---.log` is emitted if `whisper_cpp` spends too long in model load or transcription. + +Those bundles include the current status snapshot plus best-effort stack and open-file diagnostics. If the hang reproduces, capture the newest `hang-*.log` file along with `main-status.json`. From e094e82558bf4499fae3b0a4588a1d6e4e67145f Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 14:41:35 +0200 Subject: [PATCH 05/14] fix: make setup readiness wait for udev Record whether the udev reload step actually succeeded and only give the relogin-only setup message when that prerequisite is met. Also wait for udev to settle before the immediate post-setup readiness probe so fresh setups are judged against the updated device node state. Constraint: Setup should not tell users that relogging alone will fix paste injection after a failed udev reload Constraint: The post-setup readiness probe runs immediately after the helper returns Rejected: Trust the follow-up readiness probe alone | it cannot distinguish a stale device node from a reload failure Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup messaging tied to the actual side effects that completed, not just the current readiness snapshot Tested: cargo test setup::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: End-to-end sudo/udev behavior on a live system --- src/setup/report.rs | 16 ++++++++++++---- src/setup/side_effects.rs | 22 +++++++++++++--------- src/setup/tests.rs | 18 ++++++++++++++---- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/setup/report.rs b/src/setup/report.rs index d6a1dfd..2bdbb1f 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -88,9 +88,15 @@ pub(super) fn print_injection_readiness( } if setup.changed_groups { - ui.print_info( - "If you were just added to the `uinput` group, log out and back in before testing.", - ); + if setup.udev_reload_succeeded { + ui.print_info( + "If you were just added to the `uinput` group, log out and back in before testing.", + ); + } else { + ui.print_info( + "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", + ); + } } for line in readiness.fix_lines() { @@ -110,6 +116,7 @@ pub(super) fn print_setup_complete( readiness.is_ready(), setup.changed_groups, readiness.only_requires_relogin(), + setup.udev_reload_succeeded, ) ); ui.print_section("Example keybind"); @@ -121,10 +128,11 @@ pub(super) fn setup_complete_message( is_ready: bool, changed_groups: bool, only_requires_relogin: bool, + udev_reload_succeeded: bool, ) -> &'static str { if is_ready { "You can now use whispers." - } else if changed_groups && only_requires_relogin { + } else if changed_groups && only_requires_relogin && udev_reload_succeeded { "Log out and back in, then use whispers." } else if changed_groups { "Log out and back in, then finish any remaining paste injection steps above before using whispers." diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index ca58900..019102e 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -14,10 +14,18 @@ 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 udev_reload_succeeded: bool, } pub(super) async fn download_asr_model( @@ -141,10 +149,11 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.udev_reload_succeeded = true, + Err(err) => ui.print_warn(format!( "Failed to reload `udev` after updating `/dev/uinput`: {err}" - )); + )), } if outcome.changed_groups { @@ -185,12 +194,7 @@ fn ensure_uinput_module_loaded(ui: &SetupUi) -> Result<()> { fn reload_udev(ui: &SetupUi) -> Result<()> { ui.print_info("Reloading `udev` rules for `/dev/uinput`..."); run_sudo(&["udevadm", "control", "--reload"])?; - run_sudo(&[ - "udevadm", - "trigger", - "--subsystem-match=misc", - "--sysname-match=uinput", - ]) + run_sudo(UDEV_TRIGGER_ARGS) } fn add_user_to_group(ui: &SetupUi, group: &str) -> Result { diff --git a/src/setup/tests.rs b/src/setup/tests.rs index e167206..3e283f7 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -75,24 +75,34 @@ fn group_membership_success_marks_logout_as_needed() { assert!(warning.is_none()); assert!(outcome.changed_groups); + assert!(!outcome.udev_reload_succeeded); +} + +#[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), + report::setup_complete_message(false, true, true, true), "Log out and back in, then use whispers." ); assert_eq!( - report::setup_complete_message(false, true, false), + report::setup_complete_message(false, true, 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, true), "Log out and back in, then finish any remaining paste injection steps above before using whispers." ); assert_eq!( - report::setup_complete_message(false, false, false), + report::setup_complete_message(false, false, false, false), "Finish the paste injection steps above, then use whispers." ); assert_eq!( - report::setup_complete_message(true, false, false), + report::setup_complete_message(true, false, false, false), "You can now use whispers." ); } From 2348c4ab51062cf3fc915fd112c6adebe01e2a56 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 14:48:59 +0200 Subject: [PATCH 06/14] refactor: centralize setup relogin guidance Remove duplicated relogin-guidance branching from the setup reporting path so the immediate setup output and the final completion message both derive from the same recorded outcome. This keeps the recent udev-readiness fix easier to follow without changing the guarded behavior. Constraint: Cleanup stays scoped to the recent setup readiness fix files Constraint: Group-change guidance must still reflect whether the udev reload succeeded Rejected: Leave the branching duplicated in each reporting callsite | easier for the messages to drift again Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup messaging decisions attached to InjectionSetupOutcome instead of recomputing them ad hoc in each UI path Tested: cargo test setup::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: End-to-end sudo/udev behavior on a live system --- src/setup/report.rs | 20 +++++------------- src/setup/side_effects.rs | 36 ++++++++++++++++++++++++++++++-- src/setup/tests.rs | 43 ++++++++++++++++++++++++++++++++++----- 3 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/setup/report.rs b/src/setup/report.rs index 2bdbb1f..988e2c4 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -87,16 +87,8 @@ pub(super) fn print_injection_readiness( println!(" - {line}"); } - if setup.changed_groups { - if setup.udev_reload_succeeded { - ui.print_info( - "If you were just added to the `uinput` group, log out and back in before testing.", - ); - } else { - ui.print_info( - "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", - ); - } + if let Some(message) = setup.report_group_change_message() { + ui.print_info(message); } for line in readiness.fix_lines() { @@ -115,8 +107,7 @@ pub(super) fn print_setup_complete( setup_complete_message( readiness.is_ready(), setup.changed_groups, - readiness.only_requires_relogin(), - setup.udev_reload_succeeded, + setup.can_finish_with_relogin_only(readiness.only_requires_relogin()), ) ); ui.print_section("Example keybind"); @@ -127,12 +118,11 @@ pub(super) fn print_setup_complete( pub(super) fn setup_complete_message( is_ready: bool, changed_groups: bool, - only_requires_relogin: bool, - udev_reload_succeeded: bool, + can_finish_with_relogin_only: bool, ) -> &'static str { if is_ready { "You can now use whispers." - } else if changed_groups && only_requires_relogin && udev_reload_succeeded { + } else if can_finish_with_relogin_only { "Log out and back in, then use whispers." } else if changed_groups { "Log out and back in, then finish any remaining paste injection steps above before using whispers." diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 019102e..c592667 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -28,6 +28,38 @@ pub(super) struct InjectionSetupOutcome { 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 if self.udev_reload_succeeded { + Some( + "If you were just added to the `uinput` group, log out and back in before testing.", + ) + } 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.changed_groups && self.udev_reload_succeeded && only_requires_relogin + } +} + pub(super) async fn download_asr_model( ui: &SetupUi, asr_model: &'static crate::asr_model::AsrModelInfo, @@ -156,8 +188,8 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result Date: Tue, 14 Apr 2026 15:01:16 +0200 Subject: [PATCH 07/14] fix: keep setup relogin guidance correct on reruns Treat a setup rerun the same as a same-run group change when the user is already configured for the uinput group and the current run successfully reloads udev. This keeps the final setup summary aligned with the real remaining recovery step without reviving the earlier failed-reload bug. Constraint: Relogin-only guidance must stay blocked after a failed group membership change or failed udev reload Constraint: The fix should stay scoped to the setup outcome/report path and regression tests Rejected: Infer group readiness from changed_groups alone | misses reruns and manual prior membership Confidence: high Scope-risk: narrow Reversibility: clean Directive: Track whether setup left the user correctly configured for the uinput group instead of inferring that only from whether this run changed group membership Tested: cargo test setup::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: Live sudo/udev behavior --- src/setup/side_effects.rs | 9 +++++++-- src/setup/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index c592667..318ec55 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -25,6 +25,7 @@ pub(super) const UDEV_TRIGGER_ARGS: &[&str] = &[ #[derive(Debug, Clone, Copy, Default)] pub(super) struct InjectionSetupOutcome { pub changed_groups: bool, + pub group_membership_ready: bool, pub udev_reload_succeeded: bool, } @@ -56,7 +57,7 @@ impl InjectionSetupOutcome { } pub(super) fn can_finish_with_relogin_only(self, only_requires_relogin: bool) -> bool { - self.changed_groups && self.udev_reload_succeeded && only_requires_relogin + self.group_membership_ready && self.udev_reload_succeeded && only_requires_relogin } } @@ -203,9 +204,13 @@ pub(super) fn record_group_membership_change_result( match result { Ok(true) => { outcome.changed_groups = true; + outcome.group_membership_ready = true; + None + } + Ok(false) => { + outcome.group_membership_ready = true; None } - Ok(false) => None, Err(err) => Some(format!( "Failed to add the current user to the `{group}` group: {err}" )), diff --git a/src/setup/tests.rs b/src/setup/tests.rs index b190775..d60a8dc 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -63,6 +63,7 @@ fn group_membership_failures_become_warnings_without_marking_success() { .expect("errors should become warnings"); assert!(!outcome.changed_groups); + assert!(!outcome.group_membership_ready); assert!(warning.contains("Failed to add the current user")); assert!(warning.contains("group add failed")); } @@ -75,17 +76,31 @@ fn group_membership_success_marks_logout_as_needed() { assert!(warning.is_none()); assert!(outcome.changed_groups); + assert!(outcome.group_membership_ready); assert!(!outcome.udev_reload_succeeded); } +#[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); +} + #[test] fn group_change_messages_follow_recorded_reload_status() { let success = side_effects::InjectionSetupOutcome { changed_groups: true, + group_membership_ready: true, udev_reload_succeeded: true, }; let failed_reload = side_effects::InjectionSetupOutcome { changed_groups: true, + group_membership_ready: true, udev_reload_succeeded: false, }; @@ -111,6 +126,24 @@ fn group_change_messages_follow_recorded_reload_status() { ); } +#[test] +fn relogin_only_completion_allows_reruns_when_group_is_already_configured() { + let rerun = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_ready: true, + udev_reload_succeeded: true, + }; + let failed_group_update = side_effects::InjectionSetupOutcome { + changed_groups: false, + group_membership_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!(!rerun.can_finish_with_relogin_only(false)); +} + #[test] fn udev_trigger_waits_for_settle_before_rechecking() { assert!(side_effects::UDEV_TRIGGER_ARGS.contains(&"--settle")); @@ -130,6 +163,10 @@ fn setup_complete_message_stays_aligned_with_remaining_steps() { report::setup_complete_message(false, true, false), "Log out and back in, then finish any remaining paste injection steps above before using whispers." ); + assert_eq!( + report::setup_complete_message(false, 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." From 88a570a7dd861c3fc8dc20ca5761b0f5086e98db Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 15:16:58 +0200 Subject: [PATCH 08/14] fix: align setup checks with real uinput access Require read-write access when probing /dev/uinput so preflight matches the real virtual-device builder, and resolve existing uinput groups through NSS instead of only /etc/group. This keeps setup guidance accurate on systems with stricter device permissions or non-local group sources. Constraint: Injection preflight must reflect the same access mode the virtual keyboard actually needs Constraint: Group discovery must honor NSS-backed sources used by the rest of setup Rejected: Keep a write-only probe and /etc/group scan | both can report false readiness in real environments Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup and preflight environment checks aligned with the runtime code paths and NSS-backed identity lookups Tested: cargo test inject::tests:: -- --nocapture; cargo test setup::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: Live sudo/udev behavior and NSS-backed groups outside the local test environment --- src/inject/preflight.rs | 14 ++++++++--- src/inject/tests.rs | 21 ++++++++++++++++ src/setup/side_effects.rs | 53 +++++++++++++++++++++++++++++++++------ src/setup/tests.rs | 8 ++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/inject/preflight.rs b/src/inject/preflight.rs index f182ab5..c63da8f 100644 --- a/src/inject/preflight.rs +++ b/src/inject/preflight.rs @@ -129,7 +129,10 @@ impl InjectionReadinessIssue { "Log out and back in after changing group membership.".into(), ], Self::UinputUnavailable(_) => { - vec!["Check that `/dev/uinput` exists and is writable by the current user.".into()] + vec![ + "Check that `/dev/uinput` exists and is readable and writable by the current user." + .into(), + ] } } } @@ -141,7 +144,7 @@ impl InjectionReadinessIssue { "`/dev/uinput` is missing; load the `uinput` kernel module".into() } Self::UinputPermissionDenied => { - "`/dev/uinput` is present but not writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() + "`/dev/uinput` is present but not readable and writable by the current user; create a dedicated `uinput` group, add your user to it, install a `udev` rule, then log out and back in".into() } Self::UinputUnavailable(detail) => { format!("`/dev/uinput` could not be opened: {detail}") @@ -159,12 +162,15 @@ pub fn validate_injection_prerequisites() -> Result<()> { } fn probe_uinput() -> Option { - let path = Path::new(UINPUT_PATH); + probe_uinput_path(Path::new(UINPUT_PATH)) +} + +pub(super) fn probe_uinput_path(path: &Path) -> Option { if !path.exists() { return Some(InjectionReadinessIssue::MissingUinputDevice); } - match OpenOptions::new().write(true).open(path) { + match OpenOptions::new().read(true).write(true).open(path) { Ok(_) => None, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { Some(InjectionReadinessIssue::UinputPermissionDenied) diff --git a/src/inject/tests.rs b/src/inject/tests.rs index a655058..6044bd2 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -1,3 +1,5 @@ +use std::os::unix::fs::PermissionsExt; + use crate::error::WhsprError; use super::preflight::InjectionReadinessIssue; @@ -112,3 +114,22 @@ fn readiness_report_only_requires_relogin_when_permission_is_last_blocker() { ]); assert!(!additional_steps.only_requires_relogin()); } + +#[test] +fn probe_uinput_requires_read_and_write_access() { + let dir = crate::test_support::unique_temp_dir("uinput-readwrite-probe"); + let path = dir.join("uinput"); + std::fs::write(&path, []).expect("create probe file"); + + let mut perms = std::fs::metadata(&path).expect("metadata").permissions(); + perms.set_mode(0o200); + std::fs::set_permissions(&path, perms).expect("set write-only permissions"); + + let issue = preflight::probe_uinput_path(&path).expect("write-only file should fail"); + assert_eq!(issue, InjectionReadinessIssue::UinputPermissionDenied); + + let mut cleanup_perms = std::fs::metadata(&path).expect("metadata").permissions(); + cleanup_perms.set_mode(0o600); + std::fs::set_permissions(&path, cleanup_perms).expect("restore permissions"); + std::fs::remove_dir_all(&dir).expect("cleanup temp dir"); +} diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 318ec55..366ee04 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -1,4 +1,4 @@ -use std::ffi::CStr; +use std::ffi::{CStr, CString}; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; @@ -260,15 +260,52 @@ fn ensure_group_exists(ui: &SetupUi, group: &str) -> Result<()> { run_sudo(&["groupadd", "--system", group]) } -fn group_exists(group: &str) -> Result { - let group_file = std::fs::read_to_string("/etc/group").map_err(|err| { - crate::error::WhsprError::Config(format!("failed to read /etc/group: {err}")) +pub(super) fn group_exists(group: &str) -> Result { + let c_group = CString::new(group).map_err(|_| { + crate::error::WhsprError::Config(format!( + "group name `{group}` contains an interior NUL byte" + )) })?; - Ok(group_file - .lines() - .filter_map(|line| line.split(':').next()) - .any(|entry| entry == group)) + let mut buffer_len = match unsafe { libc::sysconf(libc::_SC_GETGR_R_SIZE_MAX) } { + value if value > 0 => value as usize, + _ => 1024, + }; + + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut group_entry = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = unsafe { + libc::getgrnam_r( + c_group.as_ptr(), + group_entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + return Ok(!result.is_null()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve group `{group}` via NSS: {}", + std::io::Error::from_raw_os_error(status) + ))); + } } fn current_user_in_group(username: &str, group: &str) -> Result { diff --git a/src/setup/tests.rs b/src/setup/tests.rs index d60a8dc..5b6c101 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -80,6 +80,14 @@ fn group_membership_success_marks_logout_as_needed() { 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(); From f17b874eae874634a66ac62629ea7d96a6dd0fe2 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Tue, 14 Apr 2026 15:46:11 +0200 Subject: [PATCH 09/14] fix: keep setup guidance accurate after relogin-only setup Suppress stale manual fix steps when setup has already reached the relogin-only state, and make username lookup retry when NSS reports the passwd buffer hint was too small. This keeps the automatic uinput setup path consistent across reruns and larger NSS-backed passwd records. Constraint: Relogin-only guidance must not repeat manual setup steps that already succeeded Constraint: Username lookup must retry when NSS says the passwd buffer hint was too small Rejected: Keep printing readiness fix lines unconditionally | contradicts the final setup summary when only relogin remains Rejected: Trust a single getpwuid_r buffer attempt | NSS backends can still return ERANGE after the size hint Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup reporting aligned with the actual remaining recovery step, and treat NSS buffer-size hints as advisory Tested: cargo test setup::tests:: -- --nocapture; cargo test inject::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: Live sudo/udev behavior and large NSS passwd records outside the test environment --- src/inject/mod.rs | 2 ++ src/setup/report.rs | 26 +++++++++++++++-- src/setup/side_effects.rs | 61 ++++++++++++++++++++++++++++----------- src/setup/tests.rs | 52 +++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/inject/mod.rs b/src/inject/mod.rs index 97b19e1..fc6cc87 100644 --- a/src/inject/mod.rs +++ b/src/inject/mod.rs @@ -6,6 +6,8 @@ mod preflight; mod tests; use crate::error::{Result, WhsprError}; +#[cfg(test)] +pub(crate) use preflight::InjectionReadinessIssue; pub use preflight::{InjectionReadinessReport, validate_injection_prerequisites}; const DEVICE_READY_DELAY: std::time::Duration = std::time::Duration::from_millis(120); diff --git a/src/setup/report.rs b/src/setup/report.rs index 988e2c4..7f0823c 100644 --- a/src/setup/report.rs +++ b/src/setup/report.rs @@ -87,15 +87,37 @@ pub(super) fn print_injection_readiness( println!(" - {line}"); } - if let Some(message) = setup.report_group_change_message() { + if let Some(message) = injection_readiness_info_message(readiness, setup) { ui.print_info(message); } - for line in readiness.fix_lines() { + for line in injection_readiness_fix_lines(readiness, setup) { println!(" - {line}"); } } +pub(super) fn injection_readiness_info_message( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Option<&'static str> { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Some("Log out and back in before testing.") + } else { + setup.report_group_change_message() + } +} + +pub(super) fn injection_readiness_fix_lines( + readiness: &InjectionReadinessReport, + setup: &InjectionSetupOutcome, +) -> Vec { + if setup.can_finish_with_relogin_only(readiness.only_requires_relogin()) { + Vec::new() + } else { + readiness.fix_lines() + } +} + pub(super) fn print_setup_complete( ui: &SetupUi, readiness: &InjectionReadinessReport, diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 366ee04..759cbcf 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -333,32 +333,59 @@ fn current_username() -> Result { )); } - let buffer_len = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { - value if value > 0 => value as usize, - _ => 1024, - }; - let mut buffer = vec![0u8; buffer_len]; - let mut passwd = std::mem::MaybeUninit::::uninit(); - let mut result = std::ptr::null_mut(); - - let status = unsafe { + current_username_for_uid_with(uid, |uid, passwd, buffer, result| unsafe { libc::getpwuid_r( uid, - passwd.as_mut_ptr(), + passwd, buffer.as_mut_ptr().cast(), buffer.len(), - &mut result, + result, ) + }) +} + +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, }; - if status != 0 || result.is_null() { + + loop { + let mut buffer = vec![0u8; buffer_len]; + let mut passwd = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + + let status = lookup(uid, passwd.as_mut_ptr(), &mut buffer, &mut result); + if status == 0 && !result.is_null() { + let passwd = unsafe { passwd.assume_init() }; + let name = unsafe { CStr::from_ptr(passwd.pw_name) }; + return Ok(name.to_string_lossy().into_owned()); + } + + if status == libc::ERANGE { + buffer_len = buffer_len.saturating_mul(2); + if buffer_len > 1 << 20 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}: lookup buffer exceeded 1 MiB" + ))); + } + continue; + } + + if status == 0 { + return Err(crate::error::WhsprError::Config(format!( + "failed to resolve current username for uid {uid}" + ))); + } + return Err(crate::error::WhsprError::Config(format!( - "failed to resolve current username for uid {uid}" + "failed to resolve current username for uid {uid}: {}", + std::io::Error::from_raw_os_error(status) ))); } - - let passwd = unsafe { passwd.assume_init() }; - let name = unsafe { CStr::from_ptr(passwd.pw_name) }; - Ok(name.to_string_lossy().into_owned()) } fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { diff --git a/src/setup/tests.rs b/src/setup/tests.rs index 5b6c101..ba26410 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::error::WhsprError; +use crate::inject::{InjectionReadinessIssue, InjectionReadinessReport}; use super::{CloudSetup, apply, report, side_effects}; use crate::config::{self, TranscriptionBackend, TranscriptionFallback}; @@ -134,6 +135,24 @@ fn group_change_messages_follow_recorded_reload_status() { ); } +#[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, + 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 { @@ -152,6 +171,39 @@ fn relogin_only_completion_allows_reruns_when_group_is_already_configured() { assert!(!rerun.can_finish_with_relogin_only(false)); } +#[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")); From b7ead02c15e4a831f9e02401999de4982c3cc8ab Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 15 Apr 2026 10:06:42 +0200 Subject: [PATCH 10/14] fix: keep uinput setup from hiding incomplete work Only activate the new uinput rule once the user is actually ready for it, and keep relogin-only guidance gated on the rule being in place. This prevents partial setup failures from regressing access or hiding the remaining manual work. Constraint: Reloading udev must not switch /dev/uinput to the new group before membership is ready Constraint: Relogin-only guidance must stay blocked when the udev rule still is not in place Rejected: Always reload udev after touching setup files | can activate the new group rule before membership exists Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup outcome state explicit enough to distinguish relogin-only cases from incomplete privileged setup Tested: cargo test setup::tests:: -- --nocapture; cargo test inject::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: Live sudo/udev behavior on a host that still relies on legacy input-group access --- src/setup/side_effects.rs | 58 ++++++++++++++++++++++++++-------- src/setup/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index 759cbcf..f81d2be 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -26,6 +26,7 @@ pub(super) const UDEV_TRIGGER_ARGS: &[&str] = &[ pub(super) struct InjectionSetupOutcome { pub changed_groups: bool, pub group_membership_ready: bool, + pub uinput_rule_ready: bool, pub udev_reload_succeeded: bool, } @@ -45,10 +46,6 @@ impl InjectionSetupOutcome { pub(super) fn report_group_change_message(self) -> Option<&'static str> { if !self.changed_groups { None - } else if self.udev_reload_succeeded { - Some( - "If you were just added to the `uinput` group, log out and back in before testing.", - ) } else { Some( "If you were just added to the `uinput` group, log out and back in after finishing the remaining paste injection steps.", @@ -57,7 +54,11 @@ impl InjectionSetupOutcome { } pub(super) fn can_finish_with_relogin_only(self, only_requires_relogin: bool) -> bool { - self.group_membership_ready && self.udev_reload_succeeded && only_requires_relogin + 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 } } @@ -165,10 +166,11 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.uinput_rule_ready = true, + Err(err) => ui.print_warn(format!( "Failed to install the `/dev/uinput` udev rule: {err}" - )); + )), } if let Err(err) = ensure_group_exists(ui, UINPUT_GROUP) { ui.print_warn(format!( @@ -182,11 +184,21 @@ pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result outcome.udev_reload_succeeded = true, - Err(err) => ui.print_warn(format!( - "Failed to reload `udev` after updating `/dev/uinput`: {err}" - )), + 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() { @@ -389,6 +401,11 @@ where } 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}`" @@ -401,6 +418,21 @@ fn install_root_file(ui: &SetupUi, target: &str, contents: &str) -> Result<()> { Ok(()) } +fn root_file_has_contents(path: &Path, expected: &str) -> bool { + match std::fs::read_to_string(path) { + Ok(contents) => contents == expected, + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + false + } + Err(_) => false, + } +} + fn run_sudo(args: &[&str]) -> Result<()> { let status = Command::new("sudo").args(args).status().map_err(|err| { crate::error::WhsprError::Config(format!("failed to run sudo {:?}: {err}", args)) diff --git a/src/setup/tests.rs b/src/setup/tests.rs index ba26410..dfcb3bc 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -65,6 +65,7 @@ fn group_membership_failures_become_warnings_without_marking_success() { 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")); } @@ -78,6 +79,7 @@ fn group_membership_success_marks_logout_as_needed() { assert!(warning.is_none()); assert!(outcome.changed_groups); assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); assert!(!outcome.udev_reload_succeeded); } @@ -98,6 +100,7 @@ fn existing_group_membership_marks_relogin_as_possible_without_new_group_change( assert!(warning.is_none()); assert!(!outcome.changed_groups); assert!(outcome.group_membership_ready); + assert!(!outcome.uinput_rule_ready); } #[test] @@ -105,11 +108,13 @@ 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, }; @@ -125,7 +130,9 @@ fn group_change_messages_follow_recorded_reload_status() { ); assert_eq!( success.report_group_change_message(), - Some("If you were just added to the `uinput` group, log out and back in before testing."), + 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(), @@ -143,6 +150,7 @@ fn relogin_only_readiness_collapses_to_logout_instruction() { let setup = side_effects::InjectionSetupOutcome { changed_groups: true, group_membership_ready: true, + uinput_rule_ready: true, udev_reload_succeeded: true, }; @@ -158,19 +166,75 @@ 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"); From 1f63ad54e25567b6468f6c1c6dcff45f186fb623 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 15 Apr 2026 10:40:15 +0200 Subject: [PATCH 11/14] fix: reject root-only uinput setup readiness Prevent `whispers setup` from short-circuiting on root-only `/dev/uinput` readiness, which can otherwise report success without configuring the actual desktop user. Constraint: Automatic uinput setup should be evaluated for the real desktop user, not sudo-root permissions Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep setup privilege checks explicit so root-only readiness can never masquerade as user readiness Tested: cargo test setup::tests:: -- --nocapture Not-tested: Live sudo invocation --- src/setup/side_effects.rs | 17 ++++++++++++----- src/setup/tests.rs | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/setup/side_effects.rs b/src/setup/side_effects.rs index f81d2be..efbdd8e 100644 --- a/src/setup/side_effects.rs +++ b/src/setup/side_effects.rs @@ -143,6 +143,7 @@ pub(super) fn maybe_prewarm_experimental_nemo( } pub(super) fn maybe_setup_injection_access(ui: &SetupUi) -> Result { + validate_setup_user(unsafe { libc::geteuid() })?; let readiness = crate::inject::InjectionReadinessReport::collect(); if readiness.is_ready() || !readiness.has_uinput_issue() { return Ok(InjectionSetupOutcome::default()); @@ -339,11 +340,7 @@ fn current_user_in_group(username: &str, group: &str) -> Result { fn current_username() -> Result { let uid = unsafe { libc::geteuid() }; - if uid == 0 { - return Err(crate::error::WhsprError::Config( - "run `whispers setup` as your normal user, not as root".into(), - )); - } + validate_setup_user(uid)?; current_username_for_uid_with(uid, |uid, passwd, buffer, result| unsafe { libc::getpwuid_r( @@ -356,6 +353,16 @@ fn current_username() -> 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, diff --git a/src/setup/tests.rs b/src/setup/tests.rs index dfcb3bc..8c70f6f 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -70,6 +70,17 @@ fn group_membership_failures_become_warnings_without_marking_success() { 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 group_membership_success_marks_logout_as_needed() { let mut outcome = side_effects::InjectionSetupOutcome::default(); From 3cd072df42ab78a1dd65d072320cab28e8a4b4d1 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 15 Apr 2026 10:41:08 +0200 Subject: [PATCH 12/14] docs: ship README-linked docs in release artifacts Keep the bundled and packaged README links valid by shipping the docs pages it references in release bundles and Cargo package contents. Constraint: Packaged README links should not point to files that are omitted from shipped artifacts Confidence: high Scope-risk: narrow Reversibility: clean Directive: If README starts linking to new local docs, add those files to package and release artifact manifests in the same change Tested: cargo package --list --allow-dirty; scripts/build-release-bundle.sh with temp dist dir (verified docs in tarball) Not-tested: GitHub release workflow end-to-end --- Cargo.toml | 3 +++ scripts/build-release-bundle.sh | 7 +++++++ 2 files changed, 10 insertions(+) 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/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 \ From e4a959725a84443197a2c69f49830e0a39aae52d Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 15 Apr 2026 11:02:12 +0200 Subject: [PATCH 13/14] fix: fail root setup before side effects Reject `whispers setup` at the start of the setup flow when it is launched as root so it cannot mutate config or starter files before reporting the privilege error. Constraint: Root-only setup runs must fail before any local state is written Rejected: Leave the root guard inside the later injection setup helper | config and starter files can already be mutated before that point Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the root guard at the top of `run_setup()` so later helpers cannot become the first failing point again Tested: cargo test setup::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: Live sudo invocation --- src/setup.rs | 6 ++++++ src/setup/tests.rs | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/setup.rs b/src/setup.rs index 3d7028d..047c7c4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -54,7 +54,13 @@ impl CloudSetup { } } +fn validate_setup_invoker(uid: libc::uid_t) -> Result<()> { + side_effects::validate_setup_user(uid) +} + pub async fn run_setup(config_path_override: Option<&Path>) -> Result<()> { + validate_setup_invoker(unsafe { libc::geteuid() })?; + let ui = SetupUi::new(); ui.print_header("whispers setup"); ui.blank(); diff --git a/src/setup/tests.rs b/src/setup/tests.rs index 8c70f6f..2e25ed4 100644 --- a/src/setup/tests.rs +++ b/src/setup/tests.rs @@ -81,6 +81,17 @@ fn setup_rejects_root_before_uinput_readiness_short_circuit() { } } +#[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(); From beb77b7543cd5ef6f98cb6b6b08d43d3c52164b5 Mon Sep 17 00:00:00 2001 From: OneNoted Date: Wed, 15 Apr 2026 11:08:25 +0200 Subject: [PATCH 14/14] test: stabilize wl-copy non-zero exit coverage Make the non-zero clipboard test consume stdin before exiting so CI reliably exercises the intended non-zero exit path instead of racing into a broken-pipe write. Constraint: The test should still cover the non-zero exit path, not the broken-pipe write path Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep subprocess tests deterministic by making the child process consume the expected I/O before exiting Tested: cargo test inject::tests:: -- --nocapture; cargo fmt --all -- --check; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo test Not-tested: GitHub Actions rerun still pending --- src/inject/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inject/tests.rs b/src/inject/tests.rs index 6044bd2..f1bc9d5 100644 --- a/src/inject/tests.rs +++ b/src/inject/tests.rs @@ -21,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");