Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "1"

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ fn main() {
}
```

## Tests

On Linux, `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.
Expand Down
58 changes: 56 additions & 2 deletions src/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ impl AutoLaunch {
Terminal=false",
self.app_name,
self.app_name,
self.app_path,
self.args.join(" ")
quote_xdg_exec_argument(&self.app_path),
self.args
.iter()
.map(|arg| quote_xdg_exec_argument(arg))
.collect::<Vec<_>>()
.join(" ")
);

let dir = get_dir();
Expand Down Expand Up @@ -81,3 +85,53 @@ impl AutoLaunch {
fn get_dir() -> PathBuf {
dirs::home_dir().unwrap().join(".config").join("autostart")
}

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
}

#[cfg(test)]
mod tests {
use super::*;

#[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:?}");
}
}
}
97 changes: 97 additions & 0 deletions tests/xdg_exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#[cfg(target_os = "linux")]
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::<Vec<_>>().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);
fs::create_dir(home.0.join(".config")).unwrap();
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(target_os = "linux"))]
fn main() {}