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>
This commit is contained in:
Vincent KocandLucas Nogueira authored and GitHub committed 2026-10-11 06:19:37 -03:00
1 parent f83c5d3b0f
commit 2d541bc0d1
7 files changed
+731 -75

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
// 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()))
}
+276
View File
@@ -0,0 +1,276 @@
// 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::{
fs,
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;
struct Sandbox(PathBuf);
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct ChildProcess(Child);
impl Drop for ChildProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn xdg_unregister() {
if std::env::var_os("TAURI_XDG_TEST_CHILD").is_some() {
exercise_registration();
return;
}
if !(has_command("xdg-mime") && has_command("update-desktop-database")) {
eprintln!("skipping: xdg-mime or update-desktop-database is missing");
return;
}
let root = std::env::temp_dir().join(format!(
"tauri-deep-link-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&root).unwrap();
let sandbox = Sandbox(root);
for directory in ["config", "data", "runtime", "system-config", "system-data"] {
fs::create_dir(sandbox.0.join(directory)).unwrap();
}
// Exercise quoting with a real executable path containing a space.
let executable_dir = sandbox.0.join("app directory");
fs::create_dir(&executable_dir).unwrap();
let executable = executable_dir.join("test-app");
fs::copy(std::env::current_exe().unwrap(), &executable).unwrap();
let mut child = ChildProcess(
Command::new(&executable)
.args(["--exact", "xdg_unregister", "--nocapture"])
// Use xdg-utils' generic backend without the operator's desktop or bus.
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("HOME", &sandbox.0)
.env("XDG_RUNTIME_DIR", sandbox.0.join("runtime"))
.env("XDG_CONFIG_HOME", sandbox.0.join("config"))
.env("XDG_DATA_HOME", sandbox.0.join("data"))
.env("XDG_CONFIG_DIRS", sandbox.0.join("system-config"))
.env("XDG_DATA_DIRS", sandbox.0.join("system-data"))
.env("XDG_CURRENT_DESKTOP", "X-Generic")
.env("TAURI_XDG_TEST_CHILD", "1")
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = child.0.try_wait().unwrap() {
assert!(status.success(), "XDG registration child failed: {status}");
break;
}
assert!(
Instant::now() < deadline,
"XDG registration child timed out"
);
thread::sleep(Duration::from_millis(20));
}
}
fn exercise_registration() {
let app = mock_builder()
.plugin(tauri_plugin_deep_link::init())
.build(mock_context(noop_assets()))
.unwrap();
let deep_link = app.deep_link();
let config = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap());
let data = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap());
let mimeapps_path = config.join("mimeapps.list");
fs::write(
&mimeapps_path,
"# user comment\n[Default Applications]\nx-scheme-handler/unrelated=other.desktop\n\n",
)
.unwrap();
for scheme in ["tauri-test-first", "tauri-test-second", "tauri-test-first"] {
deep_link.register(scheme).unwrap();
assert!(deep_link.is_registered(scheme).unwrap());
}
let executable = tauri::utils::platform::current_exe().unwrap();
let file_name = format!(
"{}-handler.desktop",
executable.file_name().unwrap().to_string_lossy()
);
let desktop_text = fs::read_to_string(data.join("applications").join(&file_name)).unwrap();
// INI parsing consumes quotes; verify the desktop command's quoting in the file itself.
assert_eq!(
desktop_text.lines().find(|line| line.starts_with("Exec=")),
Some(format!("Exec=\"{}\" %u", executable.display()).as_str())
);
let desktop = ini::Ini::load_from_str(&desktop_text).unwrap();
let section = desktop.section(Some("Desktop Entry")).unwrap();
let mimes: Vec<_> = section
.get("MimeType")
.unwrap()
.split(';')
.filter(|mime| !mime.is_empty())
.collect();
assert_eq!(mimes.len(), 2);
assert!(mimes.contains(&"x-scheme-handler/tauri-test-first"));
assert!(mimes.contains(&"x-scheme-handler/tauri-test-second"));
deep_link.unregister("tauri-test-first").unwrap();
assert!(!deep_link.is_registered("tauri-test-first").unwrap());
assert!(deep_link.is_registered("tauri-test-second").unwrap());
// The user's comments and blank lines survive the edit.
let mimeapps_text = fs::read_to_string(&mimeapps_path).unwrap();
assert!(mimeapps_text.starts_with("# user comment\n"));
assert!(mimeapps_text.contains("\n\n"));
let after = fs::read_to_string(data.join("applications").join(&file_name)).unwrap();
assert_eq!(
after.lines().find(|line| line.starts_with("Exec=")),
desktop_text.lines().find(|line| line.starts_with("Exec="))
);
let desktop = ini::Ini::load_from_str(&after).unwrap();
assert_eq!(
desktop
.section(Some("Desktop Entry"))
.unwrap()
.get("MimeType"),
Some("x-scheme-handler/tauri-test-second;")
);
let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
let defaults = mimeapps.section(Some("Default Applications")).unwrap();
assert!(defaults.get("x-scheme-handler/tauri-test-first").is_none());
assert_eq!(
defaults.get("x-scheme-handler/tauri-test-second"),
Some(file_name.as_str())
);
assert_eq!(
defaults.get("x-scheme-handler/unrelated"),
Some("other.desktop")
);
// An app must not remove another handler that became the user's default.
// Keep the other handler independent of xdg-utils' quoted-Exec lookup bug.
fs::write(
data.join("applications/other.desktop"),
"[Desktop Entry]\nType=Application\nName=Other handler\nExec=/bin/sh\nMimeType=x-scheme-handler/tauri-test-second;\n",
)
.unwrap();
mimeapps.with_section(Some("Default Applications")).set(
"x-scheme-handler/tauri-test-second",
format!("other.desktop;{file_name};last.desktop;"),
);
mimeapps
.with_section(Some("Added Associations"))
.set(
"x-scheme-handler/tauri-test-second",
format!("{file_name};other.desktop;"),
)
.set("x-scheme-handler/unrelated", "unrelated.desktop;");
mimeapps.write_to_file(&mimeapps_path).unwrap();
deep_link.unregister("tauri-test-second").unwrap();
let mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
let defaults = mimeapps.section(Some("Default Applications")).unwrap();
assert_eq!(
defaults.get("x-scheme-handler/tauri-test-second"),
Some("other.desktop;last.desktop;")
);
assert_eq!(
defaults.get("x-scheme-handler/unrelated"),
Some("other.desktop")
);
let added = mimeapps.section(Some("Added Associations")).unwrap();
assert_eq!(
added.get("x-scheme-handler/tauri-test-second"),
Some("other.desktop;")
);
assert_eq!(
added.get("x-scheme-handler/unrelated"),
Some("unrelated.desktop;")
);
assert!(!deep_link.is_registered("tauri-test-second").unwrap());
let desktop_path = data.join("applications").join(&file_name);
let after = fs::read_to_string(&desktop_path).unwrap();
assert_eq!(
after.lines().find(|line| line.starts_with("Exec=")),
desktop_text.lines().find(|line| line.starts_with("Exec="))
);
let desktop = ini::Ini::load_from_str(&after).unwrap();
assert!(
desktop
.section(Some("Desktop Entry"))
.unwrap()
.get("MimeType")
.is_none()
);
let other = ini::Ini::load_from_file(data.join("applications/other.desktop")).unwrap();
assert_eq!(
other
.section(Some("Desktop Entry"))
.unwrap()
.get("MimeType"),
Some("x-scheme-handler/tauri-test-second;")
);
// Removing an association must not prevent a later registration.
deep_link.register("tauri-test-first").unwrap();
assert!(deep_link.is_registered("tauri-test-first").unwrap());
deep_link.unregister("tauri-test-first").unwrap();
deep_link.unregister("tauri-test-first").unwrap();
assert!(!deep_link.is_registered("tauri-test-first").unwrap());
// A removed desktop file may still have cached associations to withdraw.
deep_link.register("tauri-test-first").unwrap();
fs::remove_file(&desktop_path).unwrap();
deep_link.unregister("tauri-test-first").unwrap();
assert!(!deep_link.is_registered("tauri-test-first").unwrap());
fs::remove_file(&mimeapps_path).unwrap();
deep_link.unregister("tauri-test-first").unwrap();
// Nothing to withdraw: the desktop database is not refreshed, so its tool is not needed.
let path = std::env::var_os("PATH").unwrap();
unsafe { std::env::set_var("PATH", "") };
deep_link.unregister("tauri-test-first").unwrap();
deep_link.unregister("tauri-never-registered").unwrap();
unsafe { std::env::set_var("PATH", path) };
// Rewrites on register keep the desktop file's quoting and escapes.
deep_link.register("tauri-test-first").unwrap();
let custom = r#"X-Custom="quoted" a\sb"#;
let text = fs::read_to_string(&desktop_path).unwrap();
fs::write(&desktop_path, format!("{text}{custom}\n")).unwrap();
deep_link.register("tauri-test-first").unwrap();
assert_eq!(
fs::read_to_string(&desktop_path).unwrap(),
format!("{text}{custom}\n")
);
deep_link.register("tauri-test-second").unwrap();
let after = fs::read_to_string(&desktop_path).unwrap();
assert!(after.lines().any(|line| line == custom));
assert_eq!(
after.lines().find(|line| line.starts_with("Exec=")),
desktop_text.lines().find(|line| line.starts_with("Exec="))
);
}
/// 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()))
}