// 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( app: &AppHandle, api: PluginApi>, ) -> crate::Result> { #[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::(&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, } /// Access to the deep-link APIs. pub struct DeepLink { pub(crate) app: AppHandle, pub(crate) plugin_handle: PluginHandle, } impl DeepLink { /// 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>> { self.plugin_handle .run_mobile_plugin::("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>(&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>(&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>(&self, _protocol: S) -> crate::Result { 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 { pub(crate) app: AppHandle, pub(crate) current: Mutex>>, pub(crate) config: Option, } impl DeepLink { /// 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, I: Iterator>(&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::().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>> { 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>(&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>(&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>(&self, _protocol: S) -> crate::Result { #[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::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::>() .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; 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 { /// Returns a reference to the [`DeepLink`] API. fn deep_link(&self) -> &DeepLink; } impl> crate::DeepLinkExt for T { fn deep_link(&self) -> &DeepLink { self.state::>().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, } 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 { self.urls } } impl DeepLink { /// 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(&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() -> TauriPlugin> { 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::>() .current .lock() .unwrap() .replace(urls.clone()); } }) .build() }