Skip to main content

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}