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`). 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` command available on the system. `update-desktop-database` is used to refresh the MIME cache when available.
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) = load_desktop_ini(&target_file) {
317                    if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) {
318                        // if the mime type is not present, add it to the list
319                        let mut change = add_list_entry(section, "MimeType", &mime_type);
320
321                        // if the exec command doesnt match, update to the new one
322                        if section.get("Exec") != Some(qualified_exec.as_str()) {
323                            section.insert("Exec", qualified_exec);
324                            change = true;
325                        }
326
327                        // if any property has changed, rewrite the .desktop file
328                        if change {
329                            desktop_file
330                                .write_to_file_policy(&target_file, ini::EscapePolicy::Nothing)?;
331                        }
332                    }
333                } else {
334                    let mut file = File::create(target_file)?;
335                    file.write_all(
336                        format!(
337                            include_str!("template.desktop"),
338                            name = self
339                                .app
340                                .config()
341                                .product_name
342                                .clone()
343                                .unwrap_or_else(|| file_name.clone()),
344                            qualified_exec = qualified_exec,
345                            mime_type = mime_type
346                        )
347                        .as_bytes(),
348                    )?;
349                }
350
351                update_desktop_database(&target)?;
352                run_command(Command::new("xdg-mime").args([
353                    "default",
354                    &file_name,
355                    mime_type.as_str(),
356                ]))?;
357
358                Ok(())
359            }
360
361            #[cfg(not(any(windows, target_os = "linux")))]
362            Err(crate::Error::UnsupportedPlatform)
363        }
364
365        /// Unregister the app as the default handler for the specified protocol.
366        ///
367        /// - `protocol`: The name of the protocol without `://`.
368        ///
369        /// ## Platform-specific:
370        ///
371        /// - **Windows**: Requires admin rights if the protocol is registered on local machine
372        ///   (this can happen when registered from the NSIS installer when the install mode is set to both or per machine)
373        /// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
374        /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
375        pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
376            #[cfg(windows)]
377            {
378                let protocol = _protocol.as_ref();
379                let path = format!("Software\\Classes\\{protocol}");
380                if LOCAL_MACHINE.open(&path).is_ok() {
381                    LOCAL_MACHINE.remove_tree(&path)?;
382                }
383                if CURRENT_USER.open(&path).is_ok() {
384                    CURRENT_USER.remove_tree(&path)?;
385                }
386                Ok(())
387            }
388
389            #[cfg(target_os = "linux")]
390            {
391                let file_name = format!(
392                    "{}-handler.desktop",
393                    tauri::utils::platform::current_exe()?
394                        .file_name()
395                        .unwrap()
396                        .to_string_lossy()
397                );
398                let scheme = format!("x-scheme-handler/{}", _protocol.as_ref());
399                // Drop the defaults first so a failed cache refresh below cannot leave them behind.
400                let mimeapps_path = self.app.path().config_dir()?.join("mimeapps.list");
401                match std::fs::read_to_string(&mimeapps_path) {
402                    Ok(mimeapps) => {
403                        if let Some(mimeapps) =
404                            remove_mimeapps_association(&mimeapps, &scheme, &file_name)
405                        {
406                            std::fs::write(&mimeapps_path, mimeapps)?;
407                        }
408                    }
409                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
410                    Err(error) => return Err(error.into()),
411                }
412
413                let target = self.app.path().data_dir()?.join("applications");
414                let desktop_path = target.join(&file_name);
415                let mut desktop_changed = false;
416                match load_desktop_ini(&desktop_path) {
417                    Ok(mut desktop) => {
418                        if let Some(section) = desktop.section_mut(Some("Desktop Entry"))
419                            && remove_list_entry(section, "MimeType", &scheme)
420                        {
421                            desktop
422                                .write_to_file_policy(&desktop_path, ini::EscapePolicy::Nothing)?;
423                            desktop_changed = true;
424                        }
425                    }
426                    Err(ini::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {}
427                    Err(error) => return Err(error.into()),
428                }
429
430                // Also clear stale cache entries left by a failed update or a removed desktop file.
431                let cached =
432                    std::fs::read_to_string(target.join("mimeinfo.cache")).is_ok_and(|cache| {
433                        cache.lines().any(|line| {
434                            line.split_once('=').is_some_and(|(key, value)| {
435                                key == scheme && value.split(';').any(|entry| entry == file_name)
436                            })
437                        })
438                    });
439                if desktop_changed || cached {
440                    update_desktop_database(&target)?;
441                }
442
443                Ok(())
444            }
445
446            #[cfg(not(any(windows, target_os = "linux")))]
447            Err(crate::Error::UnsupportedPlatform)
448        }
449
450        /// Check whether the app is the default handler for the specified protocol.
451        ///
452        /// - `protocol`: The name of the protocol without `://`.
453        ///
454        /// ## Platform-specific:
455        ///
456        /// - **Linux**: Needs the `xdg-mime` command available on the system.
457        /// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
458        pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
459            #[cfg(windows)]
460            {
461                let protocol = _protocol.as_ref();
462                let Ok(cmd_reg) = CLASSES_ROOT.open(format!("{protocol}\\shell\\open\\command"))
463                else {
464                    return Ok(false);
465                };
466
467                let registered_cmd = cmd_reg.get_string("")?;
468
469                let exe = dunce::simplified(&tauri::utils::platform::current_exe()?)
470                    .display()
471                    .to_string();
472
473                Ok(registered_cmd == format!("\"{exe}\" \"%1\""))
474            }
475            #[cfg(target_os = "linux")]
476            {
477                let file_name = format!(
478                    "{}-handler.desktop",
479                    tauri::utils::platform::current_exe()?
480                        .file_name()
481                        .unwrap()
482                        .to_string_lossy()
483                );
484
485                let output = Command::new("xdg-mime")
486                    .args([
487                        "query",
488                        "default",
489                        &format!("x-scheme-handler/{}", _protocol.as_ref()),
490                    ])
491                    .output()
492                    .inspect_err(crate::error::inspect_command_error("xdg-mime"))?;
493
494                // xdg-utils exits with 4 ("action failed") when no default handler exists.
495                if output.status.code() == Some(4) {
496                    return Ok(false);
497                }
498                check_command_status("xdg-mime", output.status)?;
499                // Backends may pad the name or print more lines, so only the first is compared.
500                let stdout = String::from_utf8_lossy(&output.stdout);
501                Ok(stdout.lines().next().map(str::trim) == Some(file_name.as_str()))
502            }
503
504            #[cfg(not(any(windows, target_os = "linux")))]
505            Err(crate::Error::UnsupportedPlatform)
506        }
507    }
508
509    /// Runs the command, failing if it cannot be spawned or exits unsuccessfully.
510    #[cfg(target_os = "linux")]
511    fn run_command(command: &mut Command) -> std::io::Result<()> {
512        let name = command.get_program().to_string_lossy().into_owned();
513        command
514            .status()
515            .and_then(|status| check_command_status(&name, status))
516            .inspect_err(crate::error::inspect_command_error(&name))
517    }
518
519    /// Refreshes the desktop entry MIME cache. `update-desktop-database` is not installed on
520    /// every distro and the cache is only an optimization, so a missing binary is not an error.
521    #[cfg(target_os = "linux")]
522    fn update_desktop_database(target: &std::path::Path) -> std::io::Result<()> {
523        const NAME: &str = "update-desktop-database";
524        match Command::new(NAME).arg(target).status() {
525            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
526                tracing::warn!(
527                    "`{NAME}` not found, skipping MIME database update. \
528                     Install `desktop-file-utils` to silence this warning."
529                );
530                Ok(())
531            }
532            result => result
533                .and_then(|status| check_command_status(NAME, status))
534                .inspect_err(crate::error::inspect_command_error(NAME)),
535        }
536    }
537
538    #[cfg(target_os = "linux")]
539    fn check_command_status(
540        command: &str,
541        status: std::process::ExitStatus,
542    ) -> std::io::Result<()> {
543        if status.success() {
544            Ok(())
545        } else {
546            Err(std::io::Error::other(format!(
547                "OS command `{command}` failed: {status}"
548            )))
549        }
550    }
551
552    /// Loads a desktop entry file keeping its values in desktop-file syntax, so quotes and
553    /// escapes (e.g. in `Exec`) survive a rewrite with [`ini::EscapePolicy::Nothing`].
554    #[cfg(target_os = "linux")]
555    fn load_desktop_ini(path: &std::path::Path) -> Result<ini::Ini, ini::Error> {
556        ini::Ini::load_from_file_opt(
557            path,
558            // rust-ini 0.21.3 added new struct fields
559            #[allow(clippy::needless_update)]
560            ini::ParseOption {
561                enabled_quote: false,
562                enabled_escape: false,
563                ..Default::default()
564            },
565        )
566    }
567
568    /// Adds `item` to the front of the `;`-separated list in `key`. Returns whether it changed.
569    #[cfg(target_os = "linux")]
570    fn add_list_entry(section: &mut ini::Properties, key: &str, item: &str) -> bool {
571        let value = section.get(key).unwrap_or_default();
572        if value.split(';').any(|entry| entry == item) {
573            return false;
574        }
575        let entries = std::iter::once(item)
576            .chain(value.split(';').filter(|entry| !entry.is_empty()))
577            .collect::<Vec<_>>()
578            .join(";");
579        section.insert(key, format!("{entries};"));
580        true
581    }
582
583    /// Removes `item` from the `;`-separated list in `key`, dropping the key once it is empty.
584    /// Returns whether it changed.
585    #[cfg(target_os = "linux")]
586    fn remove_list_entry(section: &mut ini::Properties, key: &str, item: &str) -> bool {
587        let Some(remaining) = section
588            .get(key)
589            .and_then(|value| remove_from_list(value, item))
590        else {
591            return false;
592        };
593        if remaining.is_empty() {
594            section.remove(key);
595        } else {
596            section.insert(key, remaining);
597        }
598        true
599    }
600
601    /// Returns the `;`-separated `list` without `item`, or `None` if it does not contain it.
602    #[cfg(target_os = "linux")]
603    fn remove_from_list(list: &str, item: &str) -> Option<String> {
604        if !list.split(';').any(|entry| entry == item) {
605            return None;
606        }
607        let remaining = list
608            .split(';')
609            .filter(|entry| !entry.is_empty() && *entry != item)
610            .collect::<Vec<_>>()
611            .join(";");
612        Some(if remaining.is_empty() {
613            remaining
614        } else {
615            format!("{remaining};")
616        })
617    }
618
619    /// Removes `desktop_file` from the `mime_type` associations of a `mimeapps.list`.
620    /// Edits it line by line so the user's comments and formatting survive.
621    /// Returns `None` if nothing changed.
622    #[cfg(target_os = "linux")]
623    fn remove_mimeapps_association(
624        mimeapps: &str,
625        mime_type: &str,
626        desktop_file: &str,
627    ) -> Option<String> {
628        let mut group = "";
629        let mut changed = false;
630        let mut out = String::with_capacity(mimeapps.len());
631        for line in mimeapps.split_inclusive('\n') {
632            let trimmed = line.trim();
633            if let Some(name) = trimmed.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
634                group = name;
635            } else if matches!(group, "Default Applications" | "Added Associations")
636                && let Some((key, value)) = trimmed.split_once('=')
637                && key.trim_end() == mime_type
638                && let Some(remaining) = remove_from_list(value.trim_start(), desktop_file)
639            {
640                changed = true;
641                if !remaining.is_empty() {
642                    out.push_str(&format!("{key}={remaining}"));
643                    if line.ends_with('\n') {
644                        out.push('\n');
645                    }
646                }
647                continue;
648            }
649            out.push_str(line);
650        }
651        changed.then_some(out)
652    }
653}
654
655pub use imp::DeepLink;
656use url::Url;
657
658/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the deep-link APIs.
659pub trait DeepLinkExt<R: Runtime> {
660    /// Returns a reference to the [`DeepLink`] API.
661    fn deep_link(&self) -> &DeepLink<R>;
662}
663
664impl<R: Runtime, T: Manager<R>> crate::DeepLinkExt<R> for T {
665    fn deep_link(&self) -> &DeepLink<R> {
666        self.state::<DeepLink<R>>().inner()
667    }
668}
669
670/// Event that is triggered when the app was requested to open a new URL.
671///
672/// Typed [`tauri::Event`].
673pub struct OpenUrlEvent {
674    id: EventId,
675    urls: Vec<Url>,
676}
677
678impl OpenUrlEvent {
679    /// The event ID which can be used to stop listening to the event via [`tauri::Listener::unlisten`].
680    pub fn id(&self) -> EventId {
681        self.id
682    }
683
684    /// The event URLs.
685    pub fn urls(self) -> Vec<Url> {
686        self.urls
687    }
688}
689
690impl<R: Runtime> DeepLink<R> {
691    /// Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running.
692    ///
693    /// Use `get_current` on app load to check whether your app was started via a deep link.
694    pub fn on_open_url<F: Fn(OpenUrlEvent) + Send + Sync + 'static>(&self, f: F) -> EventId {
695        self.app.listen("deep-link://new-url", move |event| {
696            if let Ok(urls) = serde_json::from_str(event.payload()) {
697                f(OpenUrlEvent {
698                    id: event.id(),
699                    urls,
700                })
701            }
702        })
703    }
704}
705
706/// Initializes the plugin.
707pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
708    Builder::new("deep-link")
709        .invoke_handler(tauri::generate_handler![
710            commands::get_current,
711            commands::register,
712            commands::unregister,
713            commands::is_registered
714        ])
715        .setup(|app, api| {
716            app.manage(init_deep_link(app, api)?);
717            Ok(())
718        })
719        .on_event(|_app, _event| {
720            #[cfg(any(target_os = "macos", target_os = "ios"))]
721            if let tauri::RunEvent::Opened { urls } = _event {
722                use tauri::Emitter;
723
724                let _ = _app.emit("deep-link://new-url", urls);
725                _app.state::<DeepLink<R>>()
726                    .current
727                    .lock()
728                    .unwrap()
729                    .replace(urls.clone());
730            }
731        })
732        .build()
733}