Files
tauri-plugins-workspace/plugins/deep-link/tests/xdg_commands.rs
T
Vincent KocandLucas Nogueira 2d541bc0d1 fix(deep-link): preserve XDG registration ownership and report failures (#3585)
* fix(deep-link): honor XDG failures and unregister associations

* test(deep-link): isolate other application handlers

* chore: satisfy release metadata and test header checks

* fix(deep-link): gate XDG helpers to Linux

The plugin only supports Linux, so FreeBSD builds warned about dead code.

* refactor(deep-link): add run_command helper

Share the spawn, status check and logging for XDG commands.

* refactor(deep-link): share desktop file list helpers

register and unregister now edit MimeType lists the same way.

* fix(deep-link): keep desktop file quoting on register

Parse raw values so a quoted Exec matches and isn't rewritten each time.

* fix(deep-link): clean mimeapps.list first on unregister

A failing update-desktop-database no longer leaves the app as the default.

* fix(deep-link): keep mimeapps.list comments on unregister

Edit it line by line instead of re-serializing it through rust-ini.

* fix(deep-link): skip cache refresh when nothing to unregister

Only run update-desktop-database if the desktop file changed or the cache still lists the app.

* fix(deep-link): treat no default handler as unregistered

xdg-mime exits with 4 on KDE and LXQt when no handler is set.

* fix(deep-link): tolerate padded xdg-mime query output

Compare the trimmed first line instead of the raw output.

* test(deep-link): run XDG tests in CI

Drop #[ignore], skip when the tools are missing, and install them on Linux CI.

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-11 06:19:37 -03:00

260 lines
9.5 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
#![cfg(target_os = "linux")]
use std::{
env, fs,
os::unix::fs::PermissionsExt,
path::PathBuf,
process::{Child, Command},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::test::{mock_builder, mock_context, noop_assets};
use tauri_plugin_deep_link::{DeepLinkExt, Error};
const TEST_NAME: &str = "xdg_command_results";
const MODE_ENV: &str = "TAURI_XDG_COMMAND_MODE";
struct Sandbox(PathBuf);
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct TestChild(Child);
impl Drop for TestChild {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn xdg_command_results() {
if let Ok(mode) = env::var(MODE_ENV) {
exercise_commands(&mode);
return;
}
let root = env::temp_dir().join(format!(
"tauri-xdg-commands-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&root).unwrap();
let sandbox = Sandbox(root);
for mode in [
"real",
"update-failure",
"register-failure",
"query-failure",
"query-no-default",
"query-padded",
"unregister-failure",
] {
// The stubbed modes run anywhere; only the real one needs the XDG tools.
if mode == "real" && !(has_command("xdg-mime") && has_command("update-desktop-database")) {
eprintln!(
"skipping the real XDG commands: xdg-mime or update-desktop-database is missing"
);
continue;
}
let home = sandbox.0.join(mode);
fs::create_dir(&home).unwrap();
for directory in [
"config",
"data",
"runtime",
"system-config",
"system-data",
"bin",
] {
fs::create_dir(home.join(directory)).unwrap();
}
for command in ["update-desktop-database", "xdg-mime"] {
let path = home.join("bin").join(command);
fs::write(
&path,
format!(
"#!/bin/sh\nprintf '%s\\n' '{command}' >> \"$HOME/calls\"\n\
if [ '{command}' = update-desktop-database ]; then\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = update-failure ] && exit 7\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = unregister-failure ] && exit 7\n\
else\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = register-failure ] && exit 7\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = query-no-default ] && exit 4\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = query-padded ] && printf ' %s \\r\\nother\\n' \"$TAURI_XDG_DESKTOP_FILE\" && exit 0\n\
printf '%s\\n' \"$TAURI_XDG_DESKTOP_FILE\"\n\
[ \"$TAURI_XDG_COMMAND_MODE\" = query-failure ] && exit 7\n\
fi\nexit 0\n"
),
)
.unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
let mut child = TestChild(
Command::new(env::current_exe().unwrap())
.args(["--exact", TEST_NAME, "--nocapture"])
// Isolate real XDG tools from the operator's desktop, bus, and files.
.env_clear()
.env(
"PATH",
if mode == "real" {
env::var_os("PATH").unwrap()
} else {
home.join("bin").into_os_string()
},
)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join("config"))
.env("XDG_DATA_HOME", home.join("data"))
.env("XDG_RUNTIME_DIR", home.join("runtime"))
.env("XDG_CONFIG_DIRS", home.join("system-config"))
.env("XDG_DATA_DIRS", home.join("system-data"))
.env("XDG_CURRENT_DESKTOP", "X-Generic")
.env(MODE_ENV, mode)
.env("TAURI_XDG_DESKTOP_FILE", desktop_file_name())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = child.0.try_wait().unwrap() {
assert!(status.success(), "XDG child {mode} failed: {status}");
break;
}
assert!(Instant::now() < deadline, "XDG child {mode} timed out");
thread::sleep(Duration::from_millis(10));
}
}
}
fn desktop_file_name() -> String {
format!(
"{}-handler.desktop",
tauri::utils::platform::current_exe()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
)
}
fn exercise_commands(mode: &str) {
let app = mock_builder()
.plugin(tauri_plugin_deep_link::init())
.build(mock_context(noop_assets()))
.unwrap();
let deep_link = app.deep_link();
let scheme = "tauri-command-test";
if mode == "query-no-default" {
// Some xdg-mime backends exit with 4 when no handler is set.
assert!(!deep_link.is_registered(scheme).unwrap());
return;
}
if mode == "query-padded" {
// Surrounding whitespace and extra lines don't hide a match.
assert!(deep_link.is_registered(scheme).unwrap());
return;
}
if mode != "real" {
let error = if mode == "query-failure" {
deep_link.is_registered(scheme).unwrap_err()
} else if mode == "unregister-failure" {
// Registration leaves its desktop file when the cache update fails.
assert!(matches!(deep_link.register(scheme), Err(Error::Io(_))));
let mimeapps_path =
PathBuf::from(env::var_os("XDG_CONFIG_HOME").unwrap()).join("mimeapps.list");
fs::write(
&mimeapps_path,
format!(
"[Default Applications]\nx-scheme-handler/{scheme}={}\n",
desktop_file_name()
),
)
.unwrap();
let error = deep_link.unregister(scheme).unwrap_err();
// Nothing is left to withdraw, so the failing cache refresh is not run again.
deep_link.unregister(scheme).unwrap();
// The default is withdrawn even though the cache refresh failed.
let mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
assert!(
mimeapps
.section(Some("Default Applications"))
.unwrap()
.get(format!("x-scheme-handler/{scheme}"))
.is_none()
);
error
} else {
deep_link.register(scheme).unwrap_err()
};
let Error::Io(error) = error else {
panic!("command failure must use the existing I/O error variant")
};
assert_eq!(error.kind(), std::io::ErrorKind::Other);
let command = if matches!(mode, "update-failure" | "unregister-failure") {
"update-desktop-database"
} else {
"xdg-mime"
};
assert!(error.to_string().contains(command));
assert!(error.to_string().contains('7'));
let calls =
fs::read_to_string(PathBuf::from(env::var_os("HOME").unwrap()).join("calls")).unwrap();
let expected = match mode {
"update-failure" => vec!["update-desktop-database"],
"register-failure" => vec!["update-desktop-database", "xdg-mime"],
"query-failure" => vec!["xdg-mime"],
"unregister-failure" => vec!["update-desktop-database", "update-desktop-database"],
_ => unreachable!(),
};
assert_eq!(calls.lines().collect::<Vec<_>>(), expected);
return;
}
deep_link.register(scheme).unwrap();
assert!(deep_link.is_registered(scheme).unwrap());
let file_name = desktop_file_name();
let other_file = format!("other-{file_name}");
let applications = PathBuf::from(env::var_os("XDG_DATA_HOME").unwrap()).join("applications");
// Use an independent handler: older xdg-utils cannot resolve a quoted Exec token.
fs::write(
applications.join(&other_file),
format!("[Desktop Entry]\nType=Application\nName=Other handler\nExec=/bin/sh\nMimeType=x-scheme-handler/{scheme};\n"),
)
.unwrap();
let mimeapps_path =
PathBuf::from(env::var_os("XDG_CONFIG_HOME").unwrap()).join("mimeapps.list");
let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
mimeapps
.with_section(Some("Default Applications"))
.set(format!("x-scheme-handler/{scheme}"), &other_file);
mimeapps.write_to_file(mimeapps_path).unwrap();
let queried = Command::new("xdg-mime")
.args(["query", "default", &format!("x-scheme-handler/{scheme}")])
.output()
.unwrap();
assert!(queried.status.success());
assert_eq!(
String::from_utf8(queried.stdout)
.unwrap()
.trim_end_matches('\n'),
other_file
);
assert!(!deep_link.is_registered(scheme).unwrap());
}
/// Whether `command` is on `PATH`, so the test can be skipped where XDG tools are missing.
fn has_command(command: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file()))
}