diff --git a/Cargo.toml b/Cargo.toml index bb63e43..b410292 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ keywords = ["launch", "auto-launch", "startup", "login", "login-items"] description = "Auto launch any application or executable at startup. Supports Windows, macOS, and Linux." exclude = ["/test-exe"] +[[test]] +name = "xdg_exec" +harness = false + [dependencies] thiserror = "^2" diff --git a/README.md b/README.md index c6fda3a..13548c7 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,13 @@ fn main() { } ``` +## Tests + +On Linux and FreeBSD, `cargo test --test xdg_exec` uses GLib's `gio launch` to +check that an XDG desktop entry starts the executable with its original arguments. +Install the `gio` command before running this test. It uses a temporary home +directory and removes its desktop entry afterwards. + ## License MIT License. See the [License](./LICENSE) file for details. diff --git a/src/linux.rs b/src/linux.rs index ef14fa2..3ca91c7 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -226,11 +226,41 @@ fn build_xdg_autostart_data(app_name: &str, app_path: &str, args: &[String]) -> Terminal=false", app_name, app_name, - app_path, - args.join(" ") + quote_xdg_exec_argument(app_path), + args.iter() + .map(|arg| quote_xdg_exec_argument(arg)) + .collect::>() + .join(" ") ) } +fn quote_xdg_exec_argument(arg: &str) -> String { + let quoted = arg.is_empty() || arg.chars().any(|c| " \t\n\r\"'\\><~|&;$*?#()`".contains(c)); + if !quoted { + return arg.replace('%', "%%"); + } + + // Desktop Entry string escaping is decoded before Exec argument quoting. + // Escape both layers so paths and literal arguments survive the launcher. + let mut escaped = String::from("\""); + for c in arg.chars() { + match c { + '\\' => escaped.push_str("\\\\\\\\"), + '"' | '`' | '$' => { + escaped.push_str("\\\\"); + escaped.push(c); + } + '\n' => escaped.push_str("\\n"), + '\t' => escaped.push_str("\\t"), + '\r' => escaped.push_str("\\r"), + '%' => escaped.push_str("%%"), + _ => escaped.push(c), + } + } + escaped.push('"'); + escaped +} + fn build_systemd_service_data(app_name: &str, app_path: &str, args: &[String]) -> String { let args_str = if args.is_empty() { String::new() @@ -291,6 +321,24 @@ mod tests { assert!(data.contains("Terminal=false")); } + #[test] + fn test_xdg_exec_quoting() { + for (input, expected) in [ + ("", "\"\""), + ("--profile=work", "--profile=work"), + ("/opt/My App/app", "\"/opt/My App/app\""), + ("two words", "\"two words\""), + ("%f", "%%f"), + ( + "line\nbreak\tand\rcarriage", + "\"line\\nbreak\\tand\\rcarriage\"", + ), + (r#"a"b\c$d`e"#, r#""a\\"b\\\\c\\$d\\`e""#), + ] { + assert_eq!(quote_xdg_exec_argument(input), expected, "{input:?}"); + } + } + #[test] fn test_build_systemd_service_data() { let data = build_systemd_service_data( diff --git a/tests/xdg_exec.rs b/tests/xdg_exec.rs new file mode 100644 index 0000000..4c1a2c7 --- /dev/null +++ b/tests/xdg_exec.rs @@ -0,0 +1,96 @@ +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn main() { + use auto_launch::AutoLaunchBuilder; + use std::{ + env, fs, + path::PathBuf, + process::Command, + thread, + time::{Duration, Instant}, + }; + + let mut args = env::args().skip(1); + if args.next().as_deref() == Some("--record-args") { + let output = args.next().unwrap(); + let pending = format!("{output}.pending"); + fs::write(&pending, args.collect::>().join("\0")).unwrap(); + fs::rename(pending, output).unwrap(); + return; + } + + // This separate test process owns HOME before starting any threads or child + // processes. A desktop launch must never touch the developer's login items. + struct TestHome(PathBuf); + impl Drop for TestHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let home = env::temp_dir().join(format!("auto-launch-xdg-{}", std::process::id())); + fs::create_dir(&home).unwrap(); + let home = TestHome(home); + env::set_var("HOME", &home.0); + env::set_var("XDG_CONFIG_HOME", home.0.join(".config")); + + let app_dir = home.0.join("Program Files"); + fs::create_dir(&app_dir).unwrap(); + let executable = app_dir.join("Autostart 'quoted' $test` \\ probe.bin"); + fs::copy(env::current_exe().unwrap(), &executable).unwrap(); + let expected = [ + "--profile=work", + "two words", + "", + "a\"b\\c$d`e", + "single'quote", + "line\nbreak\tand\rcarriage", + "%f %F %u %U %i %c %k %%", + "~ > < | & ; * ? # ( )", + "雪", + ]; + let output = home.0.join("received arguments"); + let mut arguments = vec!["--record-args", output.to_str().unwrap()]; + arguments.extend(expected); + let auto = AutoLaunchBuilder::new() + .set_app_name("XDG Exec Test") + .set_app_path(executable.to_str().unwrap()) + .set_args(&arguments) + .build() + .unwrap(); + auto.enable().unwrap(); + assert!(auto.is_enabled().unwrap()); + + let desktop = home.0.join(".config/autostart/XDG Exec Test.desktop"); + let mut launcher = Command::new("gio") + .arg("launch") + .arg(&desktop) + .spawn() + .expect("the XDG launch test requires the GLib gio command"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = launcher.try_wait().unwrap() { + assert!(status.success(), "gio could not launch the desktop entry"); + break; + } + if Instant::now() >= deadline { + let _ = launcher.kill(); + let _ = launcher.wait(); + panic!("gio launch timed out"); + } + thread::sleep(Duration::from_millis(10)); + } + while !output.exists() { + assert!( + Instant::now() < deadline, + "desktop entry did not run the executable" + ); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!(fs::read(&output).unwrap(), expected.join("\0").as_bytes()); + auto.disable().unwrap(); + assert!(!auto.is_enabled().unwrap()); + assert!(!desktop.exists()); + println!("XDG desktop launch preserved the executable path and every argument"); +} + +#[cfg(not(any(target_os = "linux", target_os = "freebsd")))] +fn main() {}