Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changes/freebsd-deep-link-xdg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"deep-link": patch
"deep-link-js": patch
---

Support runtime deep-link registration on FreeBSD with `xdg-mime` and `update-desktop-database`.
5 changes: 4 additions & 1 deletion plugins/deep-link/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ tracing = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }

[dev-dependencies]
tauri = { workspace = true, features = ["test"] }

[target."cfg(windows)".dependencies]
dunce = "1"
windows-registry = "0.5"
windows-result = "0.3"

[target."cfg(target_os = \"linux\")".dependencies]
[target."cfg(any(target_os = \"linux\", target_os = \"freebsd\"))".dependencies]
rust-ini = "0.21"
2 changes: 1 addition & 1 deletion plugins/deep-link/guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function register(protocol: string): Promise<null> {
* #### Platform-specific
*
* - **Windows:** Requires admin rights if the protocol is registered on the local machine (this can happen when registered from the NSIS installer when the install mode is set to both or per machine).
* - **Linux:** Can only unregister the scheme if it was initially registered with {@link register}. May not work on older distros.
* - **Linux / FreeBSD:** Can only unregister the scheme if it was initially registered with {@link register}. May not work on older distros.
* - **macOS / Android / iOS:** Unsupported.
*
* @returns A promise that resolves once the protocol has been unregistered.
Expand Down
10 changes: 5 additions & 5 deletions plugins/deep-link/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ pub enum Error {
#[error(transparent)]
Windows(#[from] windows_result::Error),
/// Transparent wrapper around an [`ini::Error`], returned when reading or writing the
/// `.desktop` file used to register a protocol scheme. Only used on Linux.
#[cfg(target_os = "linux")]
/// `.desktop` file used to register a protocol scheme. Only used on Linux and FreeBSD.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
#[error(transparent)]
Ini(#[from] ini::Error),
/// Transparent wrapper around an [`ini::ParseError`], returned when parsing the
/// `.desktop` file used to register a protocol scheme. Only used on Linux.
#[cfg(target_os = "linux")]
/// `.desktop` file used to register a protocol scheme. Only used on Linux and FreeBSD.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
#[error(transparent)]
ParseIni(#[from] ini::ParseError),
/// Transparent wrapper around a [`tauri::plugin::mobile::PluginInvokeError`], returned
Expand All @@ -44,7 +44,7 @@ pub enum Error {
// TODO(v3): change this into an error in v3,
// see <https://github.com/tauri-apps/plugins-workspace/pull/2970#issuecomment-3244660138>.
#[inline]
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
pub(crate) fn inspect_command_error<'a>(command: &'a str) -> impl Fn(&std::io::Error) + 'a {
move |e| {
tracing::error!("Failed to run OS command `{command}`: {e}");
Expand Down
32 changes: 18 additions & 14 deletions plugins/deep-link/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
//! Set your Tauri application as the default handler for a URL, or check which URL(s) it was
//! opened with.
//!
//! On Windows and Linux, protocol schemes can additionally be registered and unregistered at
//! runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS
//! On Windows, Linux and FreeBSD, protocol schemes can additionally be registered and unregistered
//! at runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS
//! the schemes declared in the Tauri configuration are registered at build time instead, so
//! calling those methods returns [`Error::UnsupportedPlatform`].

Expand Down Expand Up @@ -148,7 +148,7 @@ mod imp {
///
/// ## Platform-specific:
///
/// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
/// - **Linux / FreeBSD**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatform)
Expand All @@ -170,13 +170,13 @@ mod imp {
#[cfg(not(target_os = "android"))]
mod imp {
use std::sync::Mutex;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use std::{
fs::{create_dir_all, File},
io::Write,
process::Command,
};
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use tauri::Manager;
use tauri::{AppHandle, Runtime};
#[cfg(windows)]
Expand Down Expand Up @@ -262,7 +262,7 @@ mod imp {
///
/// ## Platform-specific:
///
/// - **Linux**: Needs the `xdg-mime` and `update-desktop-database` commands available on the system.
/// - **Linux / FreeBSD**: Needs the `xdg-mime` and `update-desktop-database` commands available on the system.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn register<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
#[cfg(windows)]
Expand All @@ -288,19 +288,23 @@ mod imp {
Ok(())
}

#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
let bin = tauri::utils::platform::current_exe()?;
let file_name = format!(
"{}-handler.desktop",
bin.file_name().unwrap().to_string_lossy()
);
#[cfg(target_os = "linux")]
let appimage = self.app.env().appimage;
#[cfg(target_os = "linux")]
let exec = appimage
.clone()
.unwrap_or_else(|| bin.into_os_string())
.to_string_lossy()
.to_string();
#[cfg(target_os = "freebsd")]
let exec = bin.to_string_lossy().to_string();
let qualified_exec = format!("\"{}\" %u", exec);

let target = self.app.path().data_dir()?.join("applications");
Expand Down Expand Up @@ -371,7 +375,7 @@ mod imp {
Ok(())
}

#[cfg(not(any(windows, target_os = "linux")))]
#[cfg(not(any(windows, target_os = "linux", target_os = "freebsd")))]
Err(crate::Error::UnsupportedPlatform)
}

Expand All @@ -383,7 +387,7 @@ mod imp {
///
/// - **Windows**: Requires admin rights if the protocol is registered on local machine
/// (this can happen when registered from the NSIS installer when the install mode is set to both or per machine)
/// - **Linux**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
/// - **Linux / FreeBSD**: Can only unregister the scheme if it was initially registered with [`register`](`Self::register`). May not work on older distros.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn unregister<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<()> {
#[cfg(windows)]
Expand All @@ -399,7 +403,7 @@ mod imp {
Ok(())
}

#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
let mimeapps_path = self.app.path().config_dir()?.join("mimeapps.list");
let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path)?;
Expand All @@ -425,7 +429,7 @@ mod imp {
Ok(())
}

#[cfg(not(any(windows, target_os = "linux")))]
#[cfg(not(any(windows, target_os = "linux", target_os = "freebsd")))]
Err(crate::Error::UnsupportedPlatform)
}

Expand All @@ -435,7 +439,7 @@ mod imp {
///
/// ## Platform-specific:
///
/// - **Linux**: Needs the `xdg-mime` command available on the system.
/// - **Linux / FreeBSD**: Needs the `xdg-mime` command available on the system.
/// - **macOS / Android / iOS**: Unsupported, will return [`Error::UnsupportedPlatform`](`crate::Error::UnsupportedPlatform`).
pub fn is_registered<S: AsRef<str>>(&self, _protocol: S) -> crate::Result<bool> {
#[cfg(windows)]
Expand All @@ -454,7 +458,7 @@ mod imp {

Ok(registered_cmd == format!("\"{exe}\" \"%1\""))
}
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
let file_name = format!(
"{}-handler.desktop",
Expand All @@ -476,7 +480,7 @@ mod imp {
Ok(String::from_utf8_lossy(&output.stdout).contains(&file_name))
}

#[cfg(not(any(windows, target_os = "linux")))]
#[cfg(not(any(windows, target_os = "linux", target_os = "freebsd")))]
Err(crate::Error::UnsupportedPlatform)
}
}
Expand Down
163 changes: 163 additions & 0 deletions plugins/deep-link/tests/xdg.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

#![cfg(any(target_os = "linux", target_os = "freebsd"))]

use std::{
fs,
path::PathBuf,
process::{Child, Command},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::test::{mock_builder, mock_context, noop_assets};
use tauri_plugin_deep_link::DeepLinkExt;

struct Sandbox(PathBuf);

impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}

struct ChildProcess(Child);

impl Drop for ChildProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}

#[test]
#[ignore = "requires xdg-mime and update-desktop-database"]
fn xdg_registration() {
if std::env::var_os("TAURI_XDG_TEST_CHILD").is_some() {
exercise_registration();
return;
}

let root = std::env::temp_dir().join(format!(
"tauri-deep-link-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&root).unwrap();
let sandbox = Sandbox(root);
for directory in ["config", "data", "runtime", "system-config", "system-data"] {
fs::create_dir(sandbox.0.join(directory)).unwrap();
}
// Exercise quoting with a real executable path containing a space.
let executable_dir = sandbox.0.join("app directory");
fs::create_dir(&executable_dir).unwrap();
let executable = executable_dir.join("test-app");
fs::copy(std::env::current_exe().unwrap(), &executable).unwrap();
let mut child = ChildProcess(
Command::new(&executable)
.args(["--ignored", "--exact", "xdg_registration", "--nocapture"])
// Use xdg-utils' generic backend without the operator's desktop or bus.
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap())
.env("HOME", &sandbox.0)
.env("XDG_RUNTIME_DIR", sandbox.0.join("runtime"))
.env("XDG_CONFIG_HOME", sandbox.0.join("config"))
.env("XDG_DATA_HOME", sandbox.0.join("data"))
.env("XDG_CONFIG_DIRS", sandbox.0.join("system-config"))
.env("XDG_DATA_DIRS", sandbox.0.join("system-data"))
.env("XDG_CURRENT_DESKTOP", "X-Generic")
.env("TAURI_XDG_TEST_CHILD", "1")
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = child.0.try_wait().unwrap() {
assert!(status.success(), "XDG registration child failed: {status}");
break;
}
assert!(
Instant::now() < deadline,
"XDG registration child timed out"
);
thread::sleep(Duration::from_millis(20));
}
}

fn exercise_registration() {
let app = mock_builder()
.plugin(tauri_plugin_deep_link::init())
.build(mock_context(noop_assets()))
.unwrap();
let deep_link = app.deep_link();
let config = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap());
let data = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap());
let mimeapps_path = config.join("mimeapps.list");
fs::write(
&mimeapps_path,
"[Default Applications]\nx-scheme-handler/unrelated=other.desktop\n",
)
.unwrap();

for scheme in ["tauri-test-first", "tauri-test-second", "tauri-test-first"] {
deep_link.register(scheme).unwrap();
assert!(deep_link.is_registered(scheme).unwrap());
}

let executable = tauri::utils::platform::current_exe().unwrap();
let file_name = format!(
"{}-handler.desktop",
executable.file_name().unwrap().to_string_lossy()
);
let desktop_text = fs::read_to_string(data.join("applications").join(&file_name)).unwrap();
// INI parsing consumes quotes; verify the desktop command's quoting in the file itself.
assert_eq!(
desktop_text.lines().find(|line| line.starts_with("Exec=")),
Some(format!("Exec=\"{}\" %u", executable.display()).as_str())
);
let desktop = ini::Ini::load_from_str(&desktop_text).unwrap();
let section = desktop.section(Some("Desktop Entry")).unwrap();
let mimes: Vec<_> = section
.get("MimeType")
.unwrap()
.split(';')
.filter(|mime| !mime.is_empty())
.collect();
assert_eq!(mimes.len(), 2);
assert!(mimes.contains(&"x-scheme-handler/tauri-test-first"));
assert!(mimes.contains(&"x-scheme-handler/tauri-test-second"));

deep_link.unregister("tauri-test-first").unwrap();
let mut mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
let defaults = mimeapps.section(Some("Default Applications")).unwrap();
assert!(defaults.get("x-scheme-handler/tauri-test-first").is_none());
assert_eq!(
defaults.get("x-scheme-handler/tauri-test-second"),
Some(file_name.as_str())
);
assert_eq!(
defaults.get("x-scheme-handler/unrelated"),
Some("other.desktop")
);

// An app must not remove another handler that became the user's default.
mimeapps
.with_section(Some("Default Applications"))
.set("x-scheme-handler/tauri-test-second", "other.desktop");
mimeapps.write_to_file(&mimeapps_path).unwrap();
deep_link.unregister("tauri-test-second").unwrap();
let mimeapps = ini::Ini::load_from_file(&mimeapps_path).unwrap();
let defaults = mimeapps.section(Some("Default Applications")).unwrap();
assert_eq!(
defaults.get("x-scheme-handler/tauri-test-second"),
Some("other.desktop")
);
assert_eq!(
defaults.get("x-scheme-handler/unrelated"),
Some("other.desktop")
);
}