1use 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 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 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 pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
142 Err(crate::Error::UnsupportedPlatform)
143 }
144
145 pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
154 Err(crate::Error::UnsupportedPlatform)
155 }
156
157 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 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 #[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(); let arg = args.next();
214
215 let maybe_deep_link = args.next().is_none(); 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 pub fn get_current(&self) -> crate::Result<Option<Vec<url::Url>>> {
242 return Ok(self.current.lock().unwrap().clone());
243 }
244
245 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 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 let mut change = add_list_entry(section, "MimeType", &mime_type);
320
321 if section.get("Exec") != Some(qualified_exec.as_str()) {
323 section.insert("Exec", qualified_exec);
324 change = true;
325 }
326
327 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 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 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 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 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 if output.status.code() == Some(4) {
496 return Ok(false);
497 }
498 check_command_status("xdg-mime", output.status)?;
499 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 #[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 #[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 #[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 #[allow(clippy::needless_update)]
560 ini::ParseOption {
561 enabled_quote: false,
562 enabled_escape: false,
563 ..Default::default()
564 },
565 )
566 }
567
568 #[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 #[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 #[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 #[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
658pub trait DeepLinkExt<R: Runtime> {
660 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
670pub struct OpenUrlEvent {
674 id: EventId,
675 urls: Vec<Url>,
676}
677
678impl OpenUrlEvent {
679 pub fn id(&self) -> EventId {
681 self.id
682 }
683
684 pub fn urls(self) -> Vec<Url> {
686 self.urls
687 }
688}
689
690impl<R: Runtime> DeepLink<R> {
691 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
706pub 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}