tauri_plugin_deep_link/lib.rs
1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Set your Tauri application as the default handler for a URL, or check which URL(s) it was
6//! opened with.
7//!
8//! On Windows and Linux, protocol schemes can additionally be registered and unregistered at
9//! runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS
10//! the schemes declared in the Tauri configuration are registered at build time instead, so
11//! calling those methods returns [`Error::UnsupportedPlatform`].
12
13use tauri::{
14 AppHandle, EventId, Listener, Manager, Runtime,
15 plugin::{Builder, PluginApi, TauriPlugin},
16};
17
18mod commands;
19mod config;
20mod error;
21
22pub use error::{Error, Result};
23
24#[cfg(target_os = "android")]
25const PLUGIN_IDENTIFIER: &str = "app.tauri.deep_link";
26
27fn init_deep_link<R: Runtime>(
28 app: &AppHandle<R>,
29 api: PluginApi<R, Option<config::Config>>,
30) -> crate::Result<DeepLink<R>> {
31 #[cfg(target_os = "android")]
32 {
33 let _api = api;
34
35 use tauri::{
36 Emitter,
37 ipc::{Channel, InvokeResponseBody},
38 };
39
40 let handle = _api.register_android_plugin(PLUGIN_IDENTIFIER, "DeepLinkPlugin")?;
41
42 #[derive(serde::Deserialize)]
43 struct Event {
44 url: String,
45 }
46
47 let app_handle = app.clone();
48 handle.run_mobile_plugin::<()>(
49 "setEventHandler",
50 imp::EventHandler {
51 handler: Channel::new(move |event| {
52 let url = match event {
53 InvokeResponseBody::Json(payload) => {
54 serde_json::from_str::<Event>(&payload)
55 .ok()
56 .map(|payload| payload.url)
57 }
58 _ => None,
59 };
60
61 let _ = app_handle.emit("deep-link://new-url", vec![url]);
62
63 Ok(())
64 }),
65 },
66 )?;
67
68 Ok(DeepLink {
69 app: app.clone(),
70 plugin_handle: handle,
71 })
72 }
73
74 #[cfg(target_os = "ios")]
75 return Ok(DeepLink {
76 app: app.clone(),
77 current: Default::default(),
78 config: api.config().clone(),
79 });
80
81 #[cfg(desktop)]
82 {
83 let args = std::env::args();
84 let deep_link = DeepLink {
85 app: app.clone(),
86 current: Default::default(),
87 config: api.config().clone(),
88 };
89 deep_link.handle_cli_arguments(args);
90
91 Ok(deep_link)
92 }
93}
94
95#[cfg(target_os = "android")]
96mod imp {
97 use tauri::{AppHandle, Runtime, ipc::Channel, plugin::PluginHandle};
98
99 use serde::{Deserialize, Serialize};
100
101 #[derive(Serialize)]
102 #[serde(rename_all = "camelCase")]
103 pub struct EventHandler {
104 pub handler: Channel,
105 }
106
107 #[derive(Debug, Deserialize)]
108 #[serde(rename_all = "camelCase")]
109 pub struct GetCurrentResponse {
110 pub url: Option<url::Url>,
111 }
112
113 /// Access to the deep-link APIs.
114 pub struct DeepLink<R: Runtime> {
115 pub(crate) app: AppHandle<R>,
116 pub(crate) plugin_handle: PluginHandle<R>,
117 }
118
119 impl<R: Runtime> DeepLink<R> {
120 /// 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.
121 ///
122 /// ## Platform-specific:
123 ///
124 /// - **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.
125 /// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`].
126 /// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect.
127 pub fn get_current(&self) -> crate::Result<Option<Vec<url::Url>>> {
128 self.plugin_handle
129 .run_mobile_plugin::<GetCurrentResponse>("getCurrent", ())
130 .map(|v| v.url.map(|url| vec![url]))
131 .map_err(Into::into)
132 }
133
134 /// Register the app as the default handler for the specified protocol.
135 ///
136 /// - `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.
137 ///
138 /// ## Platform-specific:
139 ///
140 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
141 pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
142 Err(crate::Error::UnsupportedPlatform)
143 }
144
145 /// Unregister the app as the default handler for the specified protocol.
146 ///
147 /// - `protocol`: The name of the protocol without `://`.
148 ///
149 /// ## Platform-specific:
150 ///
151 /// - **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.
152 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
153 pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
154 Err(crate::Error::UnsupportedPlatform)
155 }
156
157 /// Check whether the app is the default handler for the specified protocol.
158 ///
159 /// - `protocol`: The name of the protocol without `://`.
160 ///
161 /// ## Platform-specific:
162 ///
163 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
164 pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
165 Err(crate::Error::UnsupportedPlatform)
166 }
167 }
168}
169
170#[cfg(not(target_os = "android"))]
171mod imp {
172 use std::sync::Mutex;
173 #[cfg(target_os = "linux")]
174 use std::{
175 fs::{File, create_dir_all},
176 io::Write,
177 process::Command,
178 };
179 #[cfg(target_os = "linux")]
180 use tauri::Manager;
181 use tauri::{AppHandle, Runtime};
182 #[cfg(windows)]
183 use windows_registry::{CLASSES_ROOT, CURRENT_USER, LOCAL_MACHINE};
184
185 /// Access to the deep-link APIs.
186 pub struct DeepLink<R: Runtime> {
187 pub(crate) app: AppHandle<R>,
188 pub(crate) current: Mutex<Option<Vec<url::Url>>>,
189 pub(crate) config: Option<crate::config::Config>,
190 }
191
192 impl<R: Runtime> DeepLink<R> {
193 /// Checks if the provided list of arguments (which should match [`std::env::args`])
194 /// contains a deep link argument (for Linux and Windows).
195 ///
196 /// On Linux and Windows the deep links trigger a new app instance with the deep link URL as its only argument.
197 ///
198 /// 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.
199 /// To enhance its checks, we only match deep links against the schemes defined in the Tauri configuration
200 /// i.e. dynamic schemes WON'T be processed.
201 ///
202 /// This function updates the [`Self::get_current`] value and emits a `deep-link://new-url` event.
203 #[cfg(desktop)]
204 pub fn handle_cli_arguments<S: AsRef<str>, I: Iterator<Item = S>>(&self, mut args: I) {
205 use tauri::Emitter;
206
207 let Some(config) = &self.config else {
208 return;
209 };
210
211 if cfg!(windows) || cfg!(target_os = "linux") {
212 args.next(); // bin name
213 let arg = args.next();
214
215 let maybe_deep_link = args.next().is_none(); // single argument
216 if !maybe_deep_link {
217 return;
218 }
219
220 if let Some(url) = arg.and_then(|arg| arg.as_ref().parse::<url::Url>().ok()) {
221 if config.desktop.contains_scheme(&url.scheme().to_string()) {
222 let mut current = self.current.lock().unwrap();
223 current.replace(vec![url.clone()]);
224 let _ = self.app.emit("deep-link://new-url", vec![url]);
225 } else if cfg!(debug_assertions) {
226 tracing::warn!(
227 "argument {url} does not match any configured deep link scheme; skipping it"
228 );
229 }
230 }
231 }
232 }
233
234 /// 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.
235 ///
236 /// ## Platform-specific:
237 ///
238 /// - **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.
239 /// Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`].
240 /// Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect.
241 pub fn get_current(&self) -> crate::Result<Option<Vec<url::Url>>> {
242 return Ok(self.current.lock().unwrap().clone());
243 }
244
245 /// Registers all schemes defined in the configuration file.
246 ///
247 /// This is useful to ensure the schemes are registered even if the user did not install the app properly
248 /// (e.g. an AppImage that was not properly registered with an AppImage launcher).
249 pub fn register_all(&self) -> crate::Result<()> {
250 let Some(config) = &self.config else {
251 return Ok(());
252 };
253
254 for scheme in config.desktop.schemes() {
255 self.register(scheme)?;
256 }
257
258 Ok(())
259 }
260
261 /// Register the app as the default handler for the specified protocol.
262 ///
263 /// - `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.
264 ///
265 /// ## Platform-specific:
266 ///
267 /// - **Linux**: Needs the `xdg-mime` and `update-desktop-database` commands available on the system.
268 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
269 pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
270 #[cfg(windows)]
271 {
272 let protocol = _protocol.as_ref();
273 let key_base = format!("Software\\Classes\\{protocol}");
274
275 let exe = dunce::simplified(&tauri::utils::platform::current_exe()?)
276 .display()
277 .to_string();
278
279 let key_reg = CURRENT_USER.create(&key_base)?;
280 key_reg.set_string("", format!("URL:{} protocol", self.app.config().identifier))?;
281 key_reg.set_string("URL Protocol", "")?;
282
283 let icon_reg = CURRENT_USER.create(format!("{key_base}\\DefaultIcon"))?;
284 icon_reg.set_string("", format!("{exe},0"))?;
285
286 let cmd_reg = CURRENT_USER.create(format!("{key_base}\\shell\\open\\command"))?;
287
288 cmd_reg.set_string("", format!("\"{exe}\" \"%1\""))?;
289
290 Ok(())
291 }
292
293 #[cfg(target_os = "linux")]
294 {
295 let bin = tauri::utils::platform::current_exe()?;
296 let file_name = format!(
297 "{}-handler.desktop",
298 bin.file_name().unwrap().to_string_lossy()
299 );
300 let appimage = self.app.env().appimage;
301 let exec = appimage
302 .clone()
303 .unwrap_or_else(|| bin.into_os_string())
304 .to_string_lossy()
305 .to_string();
306 let qualified_exec = format!("\"{}\" %u", exec);
307
308 let target = self.app.path().data_dir()?.join("applications");
309
310 create_dir_all(&target)?;
311
312 let target_file = target.join(&file_name);
313
314 let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref());
315
316 if let Ok(mut desktop_file) = ini::Ini::load_from_file(&target_file) {
317 if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) {
318 let old_mimes = section.remove("MimeType").unwrap_or_default();
319 let mut change = false;
320
321 // if the mime type is not present, append it to the list
322 if !old_mimes.split(';').any(|mime| mime == mime_type) {
323 section.append("MimeType", format!("{mime_type};{old_mimes}"));
324 change = true;
325 } else {
326 section.insert("MimeType".to_string(), old_mimes);
327 }
328
329 // if the exec command doesnt match, update to the new one
330 let old_exec = section.remove("Exec").unwrap_or_default();
331 if old_exec != qualified_exec {
332 section.append("Exec", qualified_exec);
333 change = true;
334 } else {
335 section.insert("Exec".to_string(), old_exec.to_string());
336 }
337
338 // if any property has changed, rewrite the .desktop file
339 if change {
340 desktop_file.write_to_file(&target_file)?;
341 }
342 }
343 } else {
344 let mut file = File::create(target_file)?;
345 file.write_all(
346 format!(
347 include_str!("template.desktop"),
348 name = self
349 .app
350 .config()
351 .product_name
352 .clone()
353 .unwrap_or_else(|| file_name.clone()),
354 qualified_exec = qualified_exec,
355 mime_type = mime_type
356 )
357 .as_bytes(),
358 )?;
359 }
360
361 Command::new("update-desktop-database")
362 .arg(target)
363 .status()
364 .inspect_err(crate::error::inspect_command_error(
365 "update-desktop-database",
366 ))?;
367
368 Command::new("xdg-mime")
369 .args(["default", &file_name, mime_type.as_str()])
370 .status()
371 .inspect_err(crate::error::inspect_command_error("xdg-mime"))?;
372
373 Ok(())
374 }
375
376 #[cfg(not(any(windows, target_os = "linux")))]
377 Err(crate::Error::UnsupportedPlatform)
378 }
379
380 /// Unregister the app as the default handler for the specified protocol.
381 ///
382 /// - `protocol`: The name of the protocol without `://`.
383 ///
384 /// ## Platform-specific:
385 ///
386 /// - **Windows**: Requires admin rights if the protocol is registered on local machine
387 /// (this can happen when registered from the NSIS installer when the install mode is set to both or per machine)
388 /// - **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.
389 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
390 pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
391 #[cfg(windows)]
392 {
393 let protocol = _protocol.as_ref();
394 let path = format!("Software\\Classes\\{protocol}");
395 if LOCAL_MACHINE.open(&path).is_ok() {
396 LOCAL_MACHINE.remove_tree(&path)?;
397 }
398 if CURRENT_USER.open(&path).is_ok() {
399 CURRENT_USER.remove_tree(&path)?;
400 }
401 Ok(())
402 }
403
404 #[cfg(target_os = "linux")]
405 {
406 let file_name = format!(
407 "{}-handler.desktop",
408 tauri::utils::platform::current_exe()?
409 .file_name()
410 .unwrap()
411 .to_string_lossy()
412 );
413 let mime_type = format!("x-scheme-handler/{}", _protocol.as_ref());
414
415 // stop being the default handler
416 let mimeapps_path = self.app.path().config_dir()?.join("mimeapps.list");
417 if mimeapps_path.exists() {
418 let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path)?;
419 if let Some(section) = mimeapps.section_mut(Some("Default Applications"))
420 && section.get(&mime_type).unwrap_or_default() == file_name
421 {
422 section.remove(&mime_type);
423 }
424 mimeapps.write_to_file(&mimeapps_path)?;
425 }
426
427 // Stop declaring the scheme in the handler's `.desktop` file too: the desktop
428 // database indexes it, and with no default set `xdg-mime` falls back to that
429 // index, so the app would otherwise still be the handler.
430 let applications = self.app.path().data_dir()?.join("applications");
431 let desktop_file_path = applications.join(&file_name);
432 // Only the `MimeType` key is touched: the file may carry other changes.
433 if let Ok(mut desktop_file) = ini::Ini::load_from_file(&desktop_file_path) {
434 if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) {
435 let mime_types = section
436 .get("MimeType")
437 .unwrap_or_default()
438 .split(';')
439 .filter(|mime| !mime.is_empty() && *mime != mime_type)
440 .map(ToString::to_string)
441 .collect::<Vec<_>>();
442 if mime_types.is_empty() {
443 section.remove("MimeType");
444 } else {
445 section.insert("MimeType", mime_types.join(";"));
446 }
447 }
448 desktop_file.write_to_file(&desktop_file_path)?;
449
450 // Without the refreshed index `xdg-mime` may keep reporting the app as the
451 // handler, but the scheme is unregistered as far as the app can tell, so a
452 // missing command is not an error.
453 if let Err(e) = Command::new("update-desktop-database")
454 .arg(&applications)
455 .status()
456 {
457 tracing::warn!(
458 "Failed to run OS command `update-desktop-database`, the desktop database may still list the app as the `{mime_type}` handler: {e}"
459 );
460 }
461 }
462
463 Ok(())
464 }
465
466 #[cfg(not(any(windows, target_os = "linux")))]
467 Err(crate::Error::UnsupportedPlatform)
468 }
469
470 /// Check whether the app is the default handler for the specified protocol.
471 ///
472 /// - `protocol`: The name of the protocol without `://`.
473 ///
474 /// ## Platform-specific:
475 ///
476 /// - **Linux**: Needs the `xdg-mime` command available on the system.
477 /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
478 pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
479 #[cfg(windows)]
480 {
481 let protocol = _protocol.as_ref();
482 let Ok(cmd_reg) = CLASSES_ROOT.open(format!("{protocol}\\shell\\open\\command"))
483 else {
484 return Ok(false);
485 };
486
487 let registered_cmd = cmd_reg.get_string("")?;
488
489 let exe = dunce::simplified(&tauri::utils::platform::current_exe()?)
490 .display()
491 .to_string();
492
493 Ok(registered_cmd == format!("\"{exe}\" \"%1\""))
494 }
495 #[cfg(target_os = "linux")]
496 {
497 let file_name = format!(
498 "{}-handler.desktop",
499 tauri::utils::platform::current_exe()?
500 .file_name()
501 .unwrap()
502 .to_string_lossy()
503 );
504
505 let output = Command::new("xdg-mime")
506 .args([
507 "query",
508 "default",
509 &format!("x-scheme-handler/{}", _protocol.as_ref()),
510 ])
511 .output()
512 .inspect_err(crate::error::inspect_command_error("xdg-mime"))?;
513
514 Ok(String::from_utf8_lossy(&output.stdout).contains(&file_name))
515 }
516
517 #[cfg(not(any(windows, target_os = "linux")))]
518 Err(crate::Error::UnsupportedPlatform)
519 }
520 }
521}
522
523pub use imp::DeepLink;
524use url::Url;
525
526/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the deep-link APIs.
527pub trait DeepLinkExt<R: Runtime> {
528 /// Returns a reference to the [`DeepLink`] API.
529 fn deep_link(&self) -> &DeepLink<R>;
530}
531
532impl<R: Runtime, T: Manager<R>> crate::DeepLinkExt<R> for T {
533 fn deep_link(&self) -> &DeepLink<R> {
534 self.state::<DeepLink<R>>().inner()
535 }
536}
537
538/// Event that is triggered when the app was requested to open a new URL.
539///
540/// Typed [`tauri::Event`].
541pub struct OpenUrlEvent {
542 id: EventId,
543 urls: Vec<Url>,
544}
545
546impl OpenUrlEvent {
547 /// The event ID which can be used to stop listening to the event via [`tauri::Listener::unlisten`].
548 pub fn id(&self) -> EventId {
549 self.id
550 }
551
552 /// The event URLs.
553 pub fn urls(self) -> Vec<Url> {
554 self.urls
555 }
556}
557
558impl<R: Runtime> DeepLink<R> {
559 /// Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running.
560 ///
561 /// Use `get_current` on app load to check whether your app was started via a deep link.
562 pub fn on_open_url<F: Fn(OpenUrlEvent) + Send + Sync + 'static>(&self, f: F) -> EventId {
563 self.app.listen("deep-link://new-url", move |event| {
564 if let Ok(urls) = serde_json::from_str(event.payload()) {
565 f(OpenUrlEvent {
566 id: event.id(),
567 urls,
568 })
569 }
570 })
571 }
572}
573
574/// Initializes the plugin.
575pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
576 Builder::new("deep-link")
577 .invoke_handler(tauri::generate_handler![
578 commands::get_current,
579 commands::register,
580 commands::unregister,
581 commands::is_registered
582 ])
583 .setup(|app, api| {
584 app.manage(init_deep_link(app, api)?);
585 Ok(())
586 })
587 .on_event(|_app, _event| {
588 #[cfg(any(target_os = "macos", target_os = "ios"))]
589 if let tauri::RunEvent::Opened { urls } = _event {
590 use tauri::Emitter;
591
592 let _ = _app.emit("deep-link://new-url", urls);
593 _app.state::<DeepLink<R>>()
594 .current
595 .lock()
596 .unwrap()
597 .replace(urls.clone());
598 }
599 })
600 .build()
601}