From 2d541bc0d1fa571e7f06b8a39a4e9ecc4932f9e7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 11 Oct 2026 16:19:37 +0700 Subject: [PATCH] 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 --- .changes/deep-link-xdg-command-errors.md | 6 + .github/workflows/test-rust.yml | 2 + plugins/deep-link/Cargo.toml | 3 + plugins/deep-link/guest-js/index.ts | 2 +- plugins/deep-link/src/lib.rs | 258 ++++++++++++++------ plugins/deep-link/tests/xdg_commands.rs | 259 ++++++++++++++++++++ plugins/deep-link/tests/xdg_unregister.rs | 276 ++++++++++++++++++++++ 7 files changed, 731 insertions(+), 75 deletions(-) create mode 100644 .changes/deep-link-xdg-command-errors.md create mode 100644 plugins/deep-link/tests/xdg_commands.rs create mode 100644 plugins/deep-link/tests/xdg_unregister.rs diff --git a/.changes/deep-link-xdg-command-errors.md b/.changes/deep-link-xdg-command-errors.md new file mode 100644 index 000000000..aa152b748 --- /dev/null +++ b/.changes/deep-link-xdg-command-errors.md @@ -0,0 +1,6 @@ +--- +"deep-link": patch +"deep-link-js": patch +--- + +Return an I/O error when an XDG registration, unregistration, or query command exits unsuccessfully on Linux. Match the queried desktop filename exactly, and remove the app's MIME association when unregistering so it is not selected again by fallback. Preserve other schemes, applications, defaults, and desktop command quoting. diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index de3363d80..e1726caf1 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -112,6 +112,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev dbus + # the deep-link tests drive the real XDG tools + sudo apt-get install -y xdg-utils desktop-file-utils - uses: dtolnay/rust-toolchain@1.90 with: diff --git a/plugins/deep-link/Cargo.toml b/plugins/deep-link/Cargo.toml index be9cd5f1e..a55a49d55 100644 --- a/plugins/deep-link/Cargo.toml +++ b/plugins/deep-link/Cargo.toml @@ -45,6 +45,9 @@ tracing = { workspace = true } thiserror = { workspace = true } url = { workspace = true } +[dev-dependencies] +tauri = { workspace = true, features = ["test"] } + [target."cfg(windows)".dependencies] dunce = "1" windows-registry = "0.6" diff --git a/plugins/deep-link/guest-js/index.ts b/plugins/deep-link/guest-js/index.ts index 41b2fe882..c62e2be32 100644 --- a/plugins/deep-link/guest-js/index.ts +++ b/plugins/deep-link/guest-js/index.ts @@ -69,7 +69,7 @@ export async function register(protocol: string): Promise { * #### Platform-specific * * - **Windows:** Requires admin rights if the protocol is registered on the local machine (this can happen when registered from the NSIS installer when the install mode is set to both or per machine). - * - **Linux:** Can only unregister the scheme if it was initially registered with {@link register}. May not work on older distros. + * - **Linux:** Can only unregister the scheme if it was initially registered with {@link register}. Requires `update-desktop-database`. May not work on older distros. * - **macOS / Android / iOS:** Unsupported. * * @returns A promise that resolves once the protocol has been unregistered. diff --git a/plugins/deep-link/src/lib.rs b/plugins/deep-link/src/lib.rs index 13b323711..0f58dd4f9 100644 --- a/plugins/deep-link/src/lib.rs +++ b/plugins/deep-link/src/lib.rs @@ -148,7 +148,7 @@ mod imp { /// /// ## Platform-specific: /// - /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Needs the `update-desktop-database` command available on the system. May not work on older distros. + /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Requires `update-desktop-database`. May not work on older distros. /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). pub fn unregister>(&self, _protocol: S) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatform) @@ -313,31 +313,21 @@ mod imp { let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref()); - if let Ok(mut desktop_file) = ini::Ini::load_from_file(&target_file) { + if let Ok(mut desktop_file) = load_desktop_ini(&target_file) { if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { - let old_mimes = section.remove("MimeType").unwrap_or_default(); - let mut change = false; - - // if the mime type is not present, append it to the list - if !old_mimes.split(';').any(|mime| mime == mime_type) { - section.append("MimeType", format!("{mime_type};{old_mimes}")); - change = true; - } else { - section.insert("MimeType".to_string(), old_mimes); - } + // if the mime type is not present, add it to the list + let mut change = add_list_entry(section, "MimeType", &mime_type); // if the exec command doesnt match, update to the new one - let old_exec = section.remove("Exec").unwrap_or_default(); - if old_exec != qualified_exec { - section.append("Exec", qualified_exec); + if section.get("Exec") != Some(qualified_exec.as_str()) { + section.insert("Exec", qualified_exec); change = true; - } else { - section.insert("Exec".to_string(), old_exec.to_string()); } // if any property has changed, rewrite the .desktop file if change { - desktop_file.write_to_file(&target_file)?; + desktop_file + .write_to_file_policy(&target_file, ini::EscapePolicy::Nothing)?; } } } else { @@ -358,17 +348,12 @@ mod imp { )?; } - Command::new("update-desktop-database") - .arg(target) - .status() - .inspect_err(crate::error::inspect_command_error( - "update-desktop-database", - ))?; - - Command::new("xdg-mime") - .args(["default", &file_name, mime_type.as_str()]) - .status() - .inspect_err(crate::error::inspect_command_error("xdg-mime"))?; + run_command(Command::new("update-desktop-database").arg(target))?; + run_command(Command::new("xdg-mime").args([ + "default", + &file_name, + mime_type.as_str(), + ]))?; Ok(()) } @@ -385,7 +370,7 @@ mod imp { /// /// - **Windows**: Requires admin rights if the protocol is registered on local machine /// (this can happen when registered from the NSIS installer when the install mode is set to both or per machine) - /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Refreshes the desktop database with the `update-desktop-database` command; without it, [`is_registered`](`Self::is_registered`) may keep returning `true`. May not work on older distros. + /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). Requires `update-desktop-database`. May not work on older distros. /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`). pub fn unregister>(&self, _protocol: S) -> crate::Result<()> { #[cfg(windows)] @@ -410,54 +395,49 @@ mod imp { .unwrap() .to_string_lossy() ); - let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref()); - - // stop being the default handler + let scheme = format!("x-scheme-handler/{}", _protocol.as_ref()); + // Drop the defaults first so a failed cache refresh below cannot leave them behind. let mimeapps_path = self.app.path().config_dir()?.join("mimeapps.list"); - if mimeapps_path.exists() { - let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path)?; - if let Some(section) = mimeapps.section_mut(Some("Default Applications")) - && section.get(&mime_type).unwrap_or_default() == file_name - { - section.remove(&mime_type); - } - mimeapps.write_to_file(&mimeapps_path)?; - } - - // Stop declaring the scheme in the handler's `.desktop` file too: the desktop - // database indexes it, and with no default set `xdg-mime` falls back to that - // index, so the app would otherwise still be the handler. - let applications = self.app.path().data_dir()?.join("applications"); - let desktop_file_path = applications.join(&file_name); - // Only the `MimeType` key is touched: the file may carry other changes. - if let Ok(mut desktop_file) = ini::Ini::load_from_file(&desktop_file_path) { - if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { - let mime_types = section - .get("MimeType") - .unwrap_or_default() - .split(';') - .filter(|mime| !mime.is_empty() && *mime != mime_type) - .map(ToString::to_string) - .collect::>(); - if mime_types.is_empty() { - section.remove("MimeType"); - } else { - section.insert("MimeType", mime_types.join(";")); + match std::fs::read_to_string(&mimeapps_path) { + Ok(mimeapps) => { + if let Some(mimeapps) = + remove_mimeapps_association(&mimeapps, &scheme, &file_name) + { + std::fs::write(&mimeapps_path, mimeapps)?; } } - desktop_file.write_to_file(&desktop_file_path)?; + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } - // Without the refreshed index `xdg-mime` may keep reporting the app as the - // handler, but the scheme is unregistered as far as the app can tell, so a - // missing command is not an error. - if let Err(e) = Command::new("update-desktop-database") - .arg(&applications) - .status() - { - tracing::warn!( - "Failed to run OS command `update-desktop-database`, the desktop database may still list the app as the `{mime_type}` handler: {e}" - ); + let target = self.app.path().data_dir()?.join("applications"); + let desktop_path = target.join(&file_name); + let mut desktop_changed = false; + match load_desktop_ini(&desktop_path) { + Ok(mut desktop) => { + if let Some(section) = desktop.section_mut(Some("Desktop Entry")) + && remove_list_entry(section, "MimeType", &scheme) + { + desktop + .write_to_file_policy(&desktop_path, ini::EscapePolicy::Nothing)?; + desktop_changed = true; + } } + Err(ini::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + // Also clear stale cache entries left by a failed update or a removed desktop file. + let cached = + std::fs::read_to_string(target.join("mimeinfo.cache")).is_ok_and(|cache| { + cache.lines().any(|line| { + line.split_once('=').is_some_and(|(key, value)| { + key == scheme && value.split(';').any(|entry| entry == file_name) + }) + }) + }); + if desktop_changed || cached { + run_command(Command::new("update-desktop-database").arg(&target))?; } Ok(()) @@ -511,13 +491,143 @@ mod imp { .output() .inspect_err(crate::error::inspect_command_error("xdg-mime"))?; - Ok(String::from_utf8_lossy(&output.stdout).contains(&file_name)) + // xdg-utils exits with 4 ("action failed") when no default handler exists. + if output.status.code() == Some(4) { + return Ok(false); + } + check_command_status("xdg-mime", output.status)?; + // Backends may pad the name or print more lines, so only the first is compared. + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().next().map(str::trim) == Some(file_name.as_str())) } #[cfg(not(any(windows, target_os = "linux")))] Err(crate::Error::UnsupportedPlatform) } } + + /// Runs the command, failing if it cannot be spawned or exits unsuccessfully. + #[cfg(target_os = "linux")] + fn run_command(command: &mut Command) -> std::io::Result<()> { + let name = command.get_program().to_string_lossy().into_owned(); + command + .status() + .and_then(|status| check_command_status(&name, status)) + .inspect_err(crate::error::inspect_command_error(&name)) + } + + #[cfg(target_os = "linux")] + fn check_command_status( + command: &str, + status: std::process::ExitStatus, + ) -> std::io::Result<()> { + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "OS command `{command}` failed: {status}" + ))) + } + } + + /// Loads a desktop entry file keeping its values in desktop-file syntax, so quotes and + /// escapes (e.g. in `Exec`) survive a rewrite with [`ini::EscapePolicy::Nothing`]. + #[cfg(target_os = "linux")] + fn load_desktop_ini(path: &std::path::Path) -> Result { + ini::Ini::load_from_file_opt( + path, + ini::ParseOption { + enabled_quote: false, + enabled_escape: false, + }, + ) + } + + /// Adds `item` to the front of the `;`-separated list in `key`. Returns whether it changed. + #[cfg(target_os = "linux")] + fn add_list_entry(section: &mut ini::Properties, key: &str, item: &str) -> bool { + let value = section.get(key).unwrap_or_default(); + if value.split(';').any(|entry| entry == item) { + return false; + } + let entries = std::iter::once(item) + .chain(value.split(';').filter(|entry| !entry.is_empty())) + .collect::>() + .join(";"); + section.insert(key, format!("{entries};")); + true + } + + /// Removes `item` from the `;`-separated list in `key`, dropping the key once it is empty. + /// Returns whether it changed. + #[cfg(target_os = "linux")] + fn remove_list_entry(section: &mut ini::Properties, key: &str, item: &str) -> bool { + let Some(remaining) = section + .get(key) + .and_then(|value| remove_from_list(value, item)) + else { + return false; + }; + if remaining.is_empty() { + section.remove(key); + } else { + section.insert(key, remaining); + } + true + } + + /// Returns the `;`-separated `list` without `item`, or `None` if it does not contain it. + #[cfg(target_os = "linux")] + fn remove_from_list(list: &str, item: &str) -> Option { + if !list.split(';').any(|entry| entry == item) { + return None; + } + let remaining = list + .split(';') + .filter(|entry| !entry.is_empty() && *entry != item) + .collect::>() + .join(";"); + Some(if remaining.is_empty() { + remaining + } else { + format!("{remaining};") + }) + } + + /// Removes `desktop_file` from the `mime_type` associations of a `mimeapps.list`. + /// Edits it line by line so the user's comments and formatting survive. + /// Returns `None` if nothing changed. + #[cfg(target_os = "linux")] + fn remove_mimeapps_association( + mimeapps: &str, + mime_type: &str, + desktop_file: &str, + ) -> Option { + let mut group = ""; + let mut changed = false; + let mut out = String::with_capacity(mimeapps.len()); + for line in mimeapps.split_inclusive('\n') { + let trimmed = line.trim(); + if let Some(name) = trimmed.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + group = name; + } else if matches!(group, "Default Applications" | "Added Associations") + && let Some((key, value)) = trimmed.split_once('=') + && key.trim_end() == mime_type + && let Some(remaining) = remove_from_list(value.trim_start(), desktop_file) + { + changed = true; + if !remaining.is_empty() { + out.push_str(&format!("{key}={remaining}")); + if line.ends_with('\n') { + out.push('\n'); + } + } + continue; + } + out.push_str(line); + } + changed.then_some(out) + } } pub use imp::DeepLink; diff --git a/plugins/deep-link/tests/xdg_commands.rs b/plugins/deep-link/tests/xdg_commands.rs new file mode 100644 index 000000000..e6fb6b687 --- /dev/null +++ b/plugins/deep-link/tests/xdg_commands.rs @@ -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::>(), 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())) +} diff --git a/plugins/deep-link/tests/xdg_unregister.rs b/plugins/deep-link/tests/xdg_unregister.rs new file mode 100644 index 000000000..39cb0c33f --- /dev/null +++ b/plugins/deep-link/tests/xdg_unregister.rs @@ -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())) +}