Files
tauri-plugins-workspace/plugins/deep-link/src/lib.rs
T
8dc878fe34 fix(deep-link): check update-desktop-database existence before executing (#3383)
On Linux, `update-desktop-database` (from `desktop-file-utils`) may not
be installed, especially on KDE systems. Instead of failing with an IO
error, log a warning when the command is not found and continue.

Non-NotFound errors still propagate as before.

Fixes #2265

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-16 15:34:17 -03:00

734 lines
28 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! Set your Tauri application as the default handler for a URL, or check which URL(s) it was
//! opened with.
//!
//! On Windows and Linux, protocol schemes can additionally be registered and unregistered at
//! runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS
//! the schemes declared in the Tauri configuration are registered at build time instead, so
//! calling those methods returns [`Error::UnsupportedPlatform`].
use tauri::{
AppHandle, EventId, Listener, Manager, Runtime,
plugin::{Builder, PluginApi, TauriPlugin},
};
mod commands;
mod config;
mod error;
pub use error::{Error, Result};
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "app.tauri.deep_link";
fn init_deep_link<R: Runtime>(
app: &AppHandle<R>,
api: PluginApi<R, Option<config::Config>>,
) -> crate::Result<DeepLink<R>> {
#[cfg(target_os = "android")]
{
let _api = api;
use tauri::{
Emitter,
ipc::{Channel, InvokeResponseBody},
};
let handle = _api.register_android_plugin(PLUGIN_IDENTIFIER, "DeepLinkPlugin")?;
#[derive(serde::Deserialize)]
struct Event {
url: String,
}
let app_handle = app.clone();
handle.run_mobile_plugin::<()>(
"setEventHandler",
imp::EventHandler {
handler: Channel::new(move |event| {
let url = match event {
InvokeResponseBody::Json(payload) => {
serde_json::from_str::<Event>(&payload)
.ok()
.map(|payload| payload.url)
}
_ => None,
};
let _ = app_handle.emit("deep-link://new-url", vec![url]);
Ok(())
}),
},
)?;
Ok(DeepLink {
app: app.clone(),
plugin_handle: handle,
})
}
#[cfg(target_os = "ios")]
return Ok(DeepLink {
app: app.clone(),
current: Default::default(),
config: api.config().clone(),
});
#[cfg(desktop)]
{
let args = std::env::args();
let deep_link = DeepLink {
app: app.clone(),
current: Default::default(),
config: api.config().clone(),
};
deep_link.handle_cli_arguments(args);
Ok(deep_link)
}
}
#[cfg(target_os = "android")]
mod imp {
use tauri::{AppHandle, Runtime, ipc::Channel, plugin::PluginHandle};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EventHandler {
pub handler: Channel,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCurrentResponse {
pub url: Option<url::Url>,
}
/// Access to the deep-link APIs.
pub struct DeepLink<R: Runtime> {
pub(crate) app: AppHandle<R>,
pub(crate) plugin_handle: PluginHandle<R>,
}
impl<R: Runtime> DeepLink<R> {
/// Get the current URLs that triggered the deep link. Use this on app load to check whether your app was started via a deep link.
///
/// ## Platform-specific:
///
/// - **Windows / Linux**: This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values.
/// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`].
/// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect.
pub fn get_current(&self) -> crate::Result<Option<Vec<url::Url>>> {
self.plugin_handle
.run_mobile_plugin::<GetCurrentResponse>("getCurrent", ())
.map(|v| v.url.map(|url| vec![url]))
.map_err(Into::into)
}
/// Register the app as the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`. For example, if you want your app to handle `tauri://` links, call this method with `tauri` as the protocol.
///
/// ## Platform-specific:
///
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatform)
}
/// Unregister the app as the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`.
///
/// ## Platform-specific:
///
/// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatform)
}
/// Check whether the app is the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`.
///
/// ## Platform-specific:
///
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
Err(crate::Error::UnsupportedPlatform)
}
}
}
#[cfg(not(target_os = "android"))]
mod imp {
use std::sync::Mutex;
#[cfg(target_os = "linux")]
use std::{
fs::{File, create_dir_all},
io::Write,
process::Command,
};
#[cfg(target_os = "linux")]
use tauri::Manager;
use tauri::{AppHandle, Runtime};
#[cfg(windows)]
use windows_registry::{CLASSES_ROOT, CURRENT_USER, LOCAL_MACHINE};
/// Access to the deep-link APIs.
pub struct DeepLink<R: Runtime> {
pub(crate) app: AppHandle<R>,
pub(crate) current: Mutex<Option<Vec<url::Url>>>,
pub(crate) config: Option<crate::config::Config>,
}
impl<R: Runtime> DeepLink<R> {
/// Checks if the provided list of arguments (which should match [`std::env::args`])
/// contains a deep link argument (for Linux and Windows).
///
/// On Linux and Windows the deep links trigger a new app instance with the deep link URL as its only argument.
///
/// This function does what it can to verify if the argument is actually a deep link, though it could also be a regular CLI argument.
/// To enhance its checks, we only match deep links against the schemes defined in the Tauri configuration
/// i.e. dynamic schemes WON'T be processed.
///
/// This function updates the [`Self::get_current`] value and emits a `deep-link://new-url` event.
#[cfg(desktop)]
pub fn handle_cli_arguments<S: AsRef<str>, I: Iterator<Item = S>>(&self, mut args: I) {
use tauri::Emitter;
let Some(config) = &self.config else {
return;
};
if cfg!(windows) || cfg!(target_os = "linux") {
args.next(); // bin name
let arg = args.next();
let maybe_deep_link = args.next().is_none(); // single argument
if !maybe_deep_link {
return;
}
if let Some(url) = arg.and_then(|arg| arg.as_ref().parse::<url::Url>().ok()) {
if config.desktop.contains_scheme(&url.scheme().to_string()) {
let mut current = self.current.lock().unwrap();
current.replace(vec![url.clone()]);
let _ = self.app.emit("deep-link://new-url", vec![url]);
} else if cfg!(debug_assertions) {
tracing::warn!(
"argument {url} does not match any configured deep link scheme; skipping it"
);
}
}
}
}
/// Get the current URLs that triggered the deep link. Use this on app load to check whether your app was started via a deep link.
///
/// ## Platform-specific:
///
/// - **Windows / Linux**: This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values.
/// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`].
/// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect.
pub fn get_current(&self) -> crate::Result<Option<Vec<url::Url>>> {
return Ok(self.current.lock().unwrap().clone());
}
/// Registers all schemes defined in the configuration file.
///
/// This is useful to ensure the schemes are registered even if the user did not install the app properly
/// (e.g. an AppImage that was not properly registered with an AppImage launcher).
pub fn register_all(&self) -> crate::Result<()> {
let Some(config) = &self.config else {
return Ok(());
};
for scheme in config.desktop.schemes() {
self.register(scheme)?;
}
Ok(())
}
/// Register the app as the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`. For example, if you want your app to handle `tauri://` links, call this method with `tauri` as the protocol.
///
/// ## Platform-specific:
///
/// - **Linux**: Needs the `xdg-mime` command available on the system. `update-desktop-database` is used to refresh the MIME cache when available.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
#[cfg(windows)]
{
let protocol = _protocol.as_ref();
let key_base = format!("Software\\Classes\\{protocol}");
let exe = dunce::simplified(&tauri::utils::platform::current_exe()?)
.display()
.to_string();
let key_reg = CURRENT_USER.create(&key_base)?;
key_reg.set_string("", format!("URL:{} protocol", self.app.config().identifier))?;
key_reg.set_string("URL Protocol", "")?;
let icon_reg = CURRENT_USER.create(format!("{key_base}\\DefaultIcon"))?;
icon_reg.set_string("", format!("{exe},0"))?;
let cmd_reg = CURRENT_USER.create(format!("{key_base}\\shell\\open\\command"))?;
cmd_reg.set_string("", format!("\"{exe}\" \"%1\""))?;
Ok(())
}
#[cfg(target_os = "linux")]
{
let bin = tauri::utils::platform::current_exe()?;
let file_name = format!(
"{}-handler.desktop",
bin.file_name().unwrap().to_string_lossy()
);
let appimage = self.app.env().appimage;
let exec = appimage
.clone()
.unwrap_or_else(|| bin.into_os_string())
.to_string_lossy()
.to_string();
let qualified_exec = format!("\"{}\" %u", exec);
let target = self.app.path().data_dir()?.join("applications");
create_dir_all(&target)?;
let target_file = target.join(&file_name);
let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref());
if let Ok(mut desktop_file) = load_desktop_ini(&target_file) {
if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) {
// 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
if section.get("Exec") != Some(qualified_exec.as_str()) {
section.insert("Exec", qualified_exec);
change = true;
}
// if any property has changed, rewrite the .desktop file
if change {
desktop_file
.write_to_file_policy(&target_file, ini::EscapePolicy::Nothing)?;
}
}
} else {
let mut file = File::create(target_file)?;
file.write_all(
format!(
include_str!("template.desktop"),
name = self
.app
.config()
.product_name
.clone()
.unwrap_or_else(|| file_name.clone()),
qualified_exec = qualified_exec,
mime_type = mime_type
)
.as_bytes(),
)?;
}
update_desktop_database(&target)?;
run_command(Command::new("xdg-mime").args([
"default",
&file_name,
mime_type.as_str(),
]))?;
Ok(())
}
#[cfg(not(any(windows, target_os = "linux")))]
Err(crate::Error::UnsupportedPlatform)
}
/// Unregister the app as the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`.
///
/// ## Platform-specific:
///
/// - **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`). May not work on older distros.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
#[cfg(windows)]
{
let protocol = _protocol.as_ref();
let path = format!("Software\\Classes\\{protocol}");
if LOCAL_MACHINE.open(&path).is_ok() {
LOCAL_MACHINE.remove_tree(&path)?;
}
if CURRENT_USER.open(&path).is_ok() {
CURRENT_USER.remove_tree(&path)?;
}
Ok(())
}
#[cfg(target_os = "linux")]
{
let file_name = format!(
"{}-handler.desktop",
tauri::utils::platform::current_exe()?
.file_name()
.unwrap()
.to_string_lossy()
);
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");
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)?;
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
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 {
update_desktop_database(&target)?;
}
Ok(())
}
#[cfg(not(any(windows, target_os = "linux")))]
Err(crate::Error::UnsupportedPlatform)
}
/// Check whether the app is the default handler for the specified protocol.
///
/// - `protocol`: The name of the protocol without `://`.
///
/// ## Platform-specific:
///
/// - **Linux**: Needs the `xdg-mime` command available on the system.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
#[cfg(windows)]
{
let protocol = _protocol.as_ref();
let Ok(cmd_reg) = CLASSES_ROOT.open(format!("{protocol}\\shell\\open\\command"))
else {
return Ok(false);
};
let registered_cmd = cmd_reg.get_string("")?;
let exe = dunce::simplified(&tauri::utils::platform::current_exe()?)
.display()
.to_string();
Ok(registered_cmd == format!("\"{exe}\" \"%1\""))
}
#[cfg(target_os = "linux")]
{
let file_name = format!(
"{}-handler.desktop",
tauri::utils::platform::current_exe()?
.file_name()
.unwrap()
.to_string_lossy()
);
let output = Command::new("xdg-mime")
.args([
"query",
"default",
&format!("x-scheme-handler/{}", _protocol.as_ref()),
])
.output()
.inspect_err(crate::error::inspect_command_error("xdg-mime"))?;
// 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))
}
/// Refreshes the desktop entry MIME cache. `update-desktop-database` is not installed on
/// every distro and the cache is only an optimization, so a missing binary is not an error.
#[cfg(target_os = "linux")]
fn update_desktop_database(target: &std::path::Path) -> std::io::Result<()> {
const NAME: &str = "update-desktop-database";
match Command::new(NAME).arg(target).status() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
"`{NAME}` not found, skipping MIME database update. \
Install `desktop-file-utils` to silence this warning."
);
Ok(())
}
result => result
.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, ini::Error> {
ini::Ini::load_from_file_opt(
path,
// rust-ini 0.21.3 added new struct fields
#[allow(clippy::needless_update)]
ini::ParseOption {
enabled_quote: false,
enabled_escape: false,
..Default::default()
},
)
}
/// 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::<Vec<_>>()
.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<String> {
if !list.split(';').any(|entry| entry == item) {
return None;
}
let remaining = list
.split(';')
.filter(|entry| !entry.is_empty() && *entry != item)
.collect::<Vec<_>>()
.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<String> {
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;
use url::Url;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the deep-link APIs.
pub trait DeepLinkExt<R: Runtime> {
/// Returns a reference to the [`DeepLink`] API.
fn deep_link(&self) -> &DeepLink<R>;
}
impl<R: Runtime, T: Manager<R>> crate::DeepLinkExt<R> for T {
fn deep_link(&self) -> &DeepLink<R> {
self.state::<DeepLink<R>>().inner()
}
}
/// Event that is triggered when the app was requested to open a new URL.
///
/// Typed [`tauri::Event`].
pub struct OpenUrlEvent {
id: EventId,
urls: Vec<Url>,
}
impl OpenUrlEvent {
/// The event ID which can be used to stop listening to the event via [`tauri::Listener::unlisten`].
pub fn id(&self) -> EventId {
self.id
}
/// The event URLs.
pub fn urls(self) -> Vec<Url> {
self.urls
}
}
impl<R: Runtime> DeepLink<R> {
/// Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running.
///
/// Use `get_current` on app load to check whether your app was started via a deep link.
pub fn on_open_url<F: Fn(OpenUrlEvent) + Send + Sync + 'static>(&self, f: F) -> EventId {
self.app.listen("deep-link://new-url", move |event| {
if let Ok(urls) = serde_json::from_str(event.payload()) {
f(OpenUrlEvent {
id: event.id(),
urls,
})
}
})
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
Builder::new("deep-link")
.invoke_handler(tauri::generate_handler![
commands::get_current,
commands::register,
commands::unregister,
commands::is_registered
])
.setup(|app, api| {
app.manage(init_deep_link(app, api)?);
Ok(())
})
.on_event(|_app, _event| {
#[cfg(any(target_os = "macos", target_os = "ios"))]
if let tauri::RunEvent::Opened { urls } = _event {
use tauri::Emitter;
let _ = _app.emit("deep-link://new-url", urls);
_app.state::<DeepLink<R>>()
.current
.lock()
.unwrap()
.replace(urls.clone());
}
})
.build()
}