use std::env;
use std::error::Error;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use log::{debug, info, warn};
use tokio::process::Command as TokioCommand;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use crate::producer::ProducerResult;
use crate::widget::{Command, LaunchSpec};
pub type CommandReceiver = UnboundedReceiver<Command>;
#[derive(Clone)]
pub struct CommandSender {
inner: UnboundedSender<Command>,
}
impl CommandSender {
pub fn send(&self, command: Command) -> Result<(), Closed> {
self.inner.send(command).map_err(|_| Closed)
}
}
impl fmt::Debug for CommandSender {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandSender").finish_non_exhaustive()
}
}
pub fn command_channel() -> (CommandSender, CommandReceiver) {
let (inner, rx) = unbounded_channel();
(CommandSender { inner }, rx)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Closed;
impl fmt::Display for Closed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("command executor closed; channel receiver was dropped")
}
}
impl Error for Closed {}
pub fn expand_tilde(path: &Path) -> PathBuf {
let Some(s) = path.to_str() else {
return path.to_path_buf();
};
let Some(rest) = s.strip_prefix('~') else {
return path.to_path_buf();
};
if !rest.is_empty() && !rest.starts_with('/') {
return path.to_path_buf();
}
let Some(home) = std::env::var_os("HOME") else {
return path.to_path_buf();
};
let mut expanded = PathBuf::from(home);
if !rest.is_empty() {
expanded.push(rest.trim_start_matches('/'));
}
expanded
}
pub fn is_bare_name(path: &Path) -> bool {
path.components().count() == 1 && path.file_name().is_some_and(|name| Path::new(name) == path)
}
pub fn find_in_path(name: &Path) -> Option<PathBuf> {
let name = name.as_os_str();
let path_var = env::var_os("PATH")?;
for dir in env::split_paths(&path_var) {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub fn resolve_program(program: &Path) -> PathBuf {
let expanded = expand_tilde(program);
if expanded.is_absolute() || !is_bare_name(&expanded) {
return expanded;
}
find_in_path(&expanded).unwrap_or(expanded)
}
pub fn path_has_parent_component(path: &Path) -> bool {
path.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
}
pub fn preflight_on_click(path: &Path) -> Result<(), String> {
if path_has_parent_component(path) {
return Err(format!(
"{} must not contain '..' path components",
path.display()
));
}
if !path.is_absolute() {
return Ok(());
}
match std::fs::metadata(path) {
Ok(meta) => {
if meta.is_dir() {
return Err(format!("{} is a directory", path.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 == 0 {
return Err(format!("{} is not executable (chmod +x)", path.display()));
}
}
Ok(())
}
Err(error) => Err(format!("{}: {error}", path.display())),
}
}
pub fn format_exit_status(status: std::process::ExitStatus) -> String {
if let Some(code) = status.code() {
format!("exit status {code}")
} else {
format!("terminated by signal ({status})")
}
}
pub async fn spawn_run_program(spec: LaunchSpec) -> Result<(), String> {
let program = resolve_program(spec.program());
if is_bare_name(spec.program()) && !program.is_absolute() {
return Err(format!(
"{:?} not found in PATH (use an absolute path or fix PATH)",
spec.program()
));
}
preflight_on_click(&program)?;
let display = if spec.args().is_empty() {
program.display().to_string()
} else {
let mut out = program.display().to_string();
for arg in spec.args() {
out.push(' ');
out.push_str(arg);
}
out
};
let mut command = TokioCommand::new(&program);
command
.args(spec.args())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.kill_on_drop(false);
let mut child = command
.spawn()
.map_err(|error| format!("failed to spawn {display:?}: {error}"))?;
info!("spawned on-click program: {display}");
tokio::spawn(async move {
match child.wait().await {
Ok(status) if status.success() => {
debug!("on-click program {display:?} finished successfully");
}
Ok(status) => {
warn!(
"on-click program {display:?} failed: {}",
format_exit_status(status)
);
}
Err(error) => {
warn!("on-click program {display:?}: wait failed: {error}");
}
}
});
Ok(())
}
pub async fn run_commands(mut rx: CommandReceiver) -> ProducerResult {
while let Some(command) = rx.recv().await {
if let Command::RunProgram(spec) = command
&& let Err(reason) = spawn_run_program(spec).await
{
warn!("on-click: {reason}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.unwrap()
}
#[test]
fn send_delivers_a_command_to_the_receiver() {
let (tx, mut rx) = command_channel();
tx.send(Command::SwitchWorkspace(3))
.expect("receiver alive");
assert_eq!(rx.try_recv().ok(), Some(Command::SwitchWorkspace(3)));
}
#[test]
fn send_after_receiver_dropped_reports_closed() {
let (tx, rx) = command_channel();
drop(rx);
assert_eq!(tx.send(Command::SwitchWorkspace(1)), Err(Closed));
}
#[test]
fn expand_tilde_replaces_a_leading_tilde_with_home() {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.expect("HOME is set in this test environment");
let mut expected = home.clone();
expected.push("scripts/bluetooth.sh");
assert_eq!(
expand_tilde(std::path::Path::new("~/scripts/bluetooth.sh")),
expected
);
}
#[test]
fn expand_tilde_with_a_bare_tilde_replaces_with_home() {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.expect("HOME is set in this test environment");
assert_eq!(expand_tilde(std::path::Path::new("~")), home);
}
#[test]
fn expand_tilde_leaves_an_absolute_path_unchanged() {
assert_eq!(
expand_tilde(std::path::Path::new("/usr/bin/blueman-manager")),
PathBuf::from("/usr/bin/blueman-manager")
);
}
#[test]
fn expand_tilde_leaves_a_mid_path_tilde_unchanged() {
assert_eq!(
expand_tilde(std::path::Path::new("/tmp/~snapshot")),
PathBuf::from("/tmp/~snapshot")
);
assert_eq!(
expand_tilde(std::path::Path::new("~user/path")),
PathBuf::from("~user/path")
);
}
#[test]
fn preflight_allows_bare_path_names() {
assert!(preflight_on_click(Path::new("pavucontrol")).is_ok());
assert!(preflight_on_click(Path::new("gnome-calendar")).is_ok());
}
#[test]
fn is_bare_name_detects_single_components() {
assert!(is_bare_name(Path::new("pavucontrol")));
assert!(!is_bare_name(Path::new("/usr/bin/pavucontrol")));
assert!(!is_bare_name(Path::new("bin/pavucontrol")));
assert!(!is_bare_name(Path::new("./pavucontrol")));
}
#[test]
fn find_in_path_locates_true() {
let found = find_in_path(Path::new("true")).expect("true in PATH");
assert!(found.is_absolute());
assert!(found.ends_with("true") || found.file_name() == Some("true".as_ref()));
}
#[test]
fn resolve_program_leaves_absolute_paths() {
assert_eq!(
resolve_program(Path::new("/usr/bin/pavucontrol")),
PathBuf::from("/usr/bin/pavucontrol")
);
}
#[test]
fn preflight_rejects_missing_absolute_paths() {
let err = preflight_on_click(Path::new("/this/path/definitely/does/not/exist"))
.expect_err("missing file");
assert!(err.contains("does/not/exist"), "{err}");
}
#[test]
fn preflight_rejects_parent_dir_components() {
let err = preflight_on_click(Path::new("/tmp/../etc/passwd")).expect_err("parent");
assert!(err.contains(".."), "{err}");
let err = preflight_on_click(Path::new("../bin/evil")).expect_err("relative parent");
assert!(err.contains(".."), "{err}");
}
#[test]
fn preflight_rejects_non_executable_scripts() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("no-exec.sh");
std::fs::write(&script, "#!/bin/sh\n").expect("write");
let mut perms = std::fs::metadata(&script).expect("stat").permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&script, perms).expect("chmod");
let err = preflight_on_click(&script).expect_err("not executable");
assert!(err.contains("not executable"), "{err}");
}
#[test]
fn format_exit_status_reports_codes() {
let status = std::process::Command::new("true").status().expect("true");
assert_eq!(format_exit_status(status), "exit status 0");
let status = std::process::Command::new("false").status().expect("false");
assert_eq!(format_exit_status(status), "exit status 1");
}
#[test]
fn run_commands_ignores_non_run_program_commands() {
let rt = test_runtime();
let (tx, rx) = command_channel();
tx.send(Command::SwitchWorkspace(1)).unwrap();
tx.send(Command::ActivateTrayItem {
key: "foo".to_string(),
x: 0,
y: 0,
})
.unwrap();
drop(tx);
rt.block_on(run_commands(rx)).unwrap();
}
#[test]
fn run_commands_spawns_a_run_program_path_directly() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("clicked.marker");
let script = dir.path().join("on-click.sh");
std::fs::write(&script, format!("#!/bin/sh\ntouch {}\n", marker.display()))
.expect("write script");
let mut perms = std::fs::metadata(&script).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod");
let rt = test_runtime();
let (tx, rx) = command_channel();
tx.send(Command::RunProgram(LaunchSpec::program_only(
script.clone(),
)))
.unwrap();
drop(tx);
rt.block_on(run_commands(rx)).unwrap();
let mut waited = std::time::Duration::ZERO;
let step = std::time::Duration::from_millis(20);
while !marker.exists() && waited < std::time::Duration::from_secs(2) {
std::thread::sleep(step);
waited += step;
}
assert!(
marker.exists(),
"executor spawned the script and the marker file appeared"
);
}
#[test]
fn run_commands_swallows_a_spawn_failure_without_ending_the_loop() {
let rt = test_runtime();
let (tx, rx) = command_channel();
tx.send(Command::RunProgram(LaunchSpec::program_only(
PathBuf::from("/this/path/definitely/does/not/exist"),
)))
.unwrap();
tx.send(Command::SwitchWorkspace(2)).unwrap();
drop(tx);
rt.block_on(run_commands(rx)).unwrap();
}
#[test]
fn spawn_run_program_fails_preflight_for_missing_absolute_path() {
let rt = test_runtime();
let err = rt
.block_on(spawn_run_program(LaunchSpec::program_only(PathBuf::from(
"/this/path/definitely/does/not/exist",
))))
.expect_err("preflight");
assert!(err.contains("does/not/exist"), "{err}");
}
#[test]
fn spawn_run_program_rejects_unknown_bare_names() {
let rt = test_runtime();
let err = rt
.block_on(spawn_run_program(LaunchSpec::program_only(
"tablero-definitely-not-on-path-xyz",
)))
.expect_err("missing bare name");
assert!(err.contains("not found in PATH"), "{err}");
}
#[test]
fn spawn_run_program_passes_arguments() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("arg.marker");
let script = dir.path().join("with-arg.sh");
std::fs::write(
&script,
format!("#!/bin/sh\nprintf '%s' \"$1\" > {}\n", marker.display()),
)
.expect("write");
let mut perms = std::fs::metadata(&script).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod");
let rt = test_runtime();
let spec = LaunchSpec::with_args(script, vec!["hello-arg".into()]);
rt.block_on(spawn_run_program(spec)).expect("spawn");
let mut waited = std::time::Duration::ZERO;
let step = std::time::Duration::from_millis(20);
while !marker.exists() && waited < std::time::Duration::from_secs(2) {
std::thread::sleep(step);
waited += step;
}
assert_eq!(std::fs::read_to_string(&marker).expect("read"), "hello-arg");
}
#[test]
fn spawn_run_program_reaps_a_failing_script() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("fail.sh");
std::fs::write(&script, "#!/bin/sh\nexit 42\n").expect("write");
let mut perms = std::fs::metadata(&script).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod");
let rt = test_runtime();
rt.block_on(spawn_run_program(LaunchSpec::program_only(script)))
.expect("spawn starts");
std::thread::sleep(std::time::Duration::from_millis(100));
}
#[test]
fn spawn_run_program_expands_tilde_before_preflight() {
use std::os::unix::fs::PermissionsExt;
let home = std::env::var_os("HOME").expect("HOME");
let dir = tempfile::tempdir_in(&home).expect("tempdir in home");
let rel = dir
.path()
.strip_prefix(&home)
.expect("tempdir under HOME")
.join("tilde.sh");
let tilde_path = PathBuf::from(format!("~/{}", rel.display()));
let absolute = expand_tilde(&tilde_path);
std::fs::write(&absolute, "#!/bin/sh\nexit 0\n").expect("write");
let mut perms = std::fs::metadata(&absolute).expect("stat").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&absolute, perms).expect("chmod");
let rt = test_runtime();
rt.block_on(spawn_run_program(LaunchSpec::program_only(tilde_path)))
.expect("tilde path resolves and spawns");
std::thread::sleep(std::time::Duration::from_millis(100));
}
#[test]
fn launch_spec_parse_splits_whitespace() {
let spec = LaunchSpec::parse("gtk-launch org.gnome.Calendar").unwrap();
assert_eq!(spec.program(), Path::new("gtk-launch"));
assert_eq!(spec.args(), &["org.gnome.Calendar".to_string()]);
}
}