feat(core): add support to universal app links on macOS (#14031)

* feat(core): add support to universal app links on macOS

follow-up for https://github.com/tauri-apps/tao/pull/1108

* fix ci

* clippy

* ignore empty schemes
This commit is contained in:
Lucas Fernandes Nogueira
2025-10-07 09:27:30 -03:00
committed by GitHub
parent 20e53a4b95
commit cc8c0b5317
14 changed files with 115 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"tauri-bundler": minor:feat
---
Support providing `plist::Value` as macOS entitlements.

View File

@@ -0,0 +1,7 @@
---
"tauri-cli": minor:feat
"@tauri-apps/cli": minor:feat
"tauri-utils": minor:feat
---
Added support to universal app links on macOS with the `plugins > deep-link > desktop > domains` configuration.

5
Cargo.lock generated
View File

@@ -8411,11 +8411,12 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.34.1" version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65dc99ae111a3255027d1eca24a3833bb3267d4556a6defddb455f3ca4f5b6c" checksum = "4daa814018fecdfb977b59a094df4bd43b42e8e21f88fddfc05807e6f46efaaf"
dependencies = [ dependencies = [
"bitflags 2.7.0", "bitflags 2.7.0",
"block2 0.6.0",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",

View File

@@ -71,3 +71,4 @@ opt-level = "s"
schemars_derive = { git = 'https://github.com/tauri-apps/schemars.git', branch = 'feat/preserve-description-newlines' } schemars_derive = { git = 'https://github.com/tauri-apps/schemars.git', branch = 'feat/preserve-description-newlines' }
tauri = { path = "./crates/tauri" } tauri = { path = "./crates/tauri" }
tauri-plugin = { path = "./crates/tauri-plugin" } tauri-plugin = { path = "./crates/tauri-plugin" }
tauri-utils = { path = "./crates/tauri-utils" }

View File

@@ -46,6 +46,7 @@ regex = "1"
goblin = "0.9" goblin = "0.9"
plist = "1" plist = "1"
[target."cfg(target_os = \"windows\")".dependencies] [target."cfg(target_os = \"windows\")".dependencies]
bitness = "0.4" bitness = "0.4"
windows-registry = "0.5" windows-registry = "0.5"

View File

@@ -45,8 +45,8 @@ pub use self::{
category::AppCategory, category::AppCategory,
settings::{ settings::{
AppImageSettings, BundleBinary, BundleSettings, CustomSignCommandSettings, DebianSettings, AppImageSettings, BundleBinary, BundleSettings, CustomSignCommandSettings, DebianSettings,
DmgSettings, IosSettings, MacOsSettings, PackageSettings, PackageType, PlistKind, Position, DmgSettings, Entitlements, IosSettings, MacOsSettings, PackageSettings, PackageType, PlistKind,
RpmSettings, Settings, SettingsBuilder, Size, UpdaterSettings, Position, RpmSettings, Settings, SettingsBuilder, Size, UpdaterSettings,
}, },
}; };
pub use settings::{NsisSettings, WindowsSettings, WixLanguage, WixLanguageConfig, WixSettings}; pub use settings::{NsisSettings, WindowsSettings, WixLanguage, WixLanguageConfig, WixSettings};

View File

@@ -312,6 +312,7 @@ fn create_info_plist(
plist::Value::Array( plist::Value::Array(
protocols protocols
.iter() .iter()
.filter(|p| !p.schemes.is_empty())
.map(|protocol| { .map(|protocol| {
let mut dict = plist::Dictionary::new(); let mut dict = plist::Dictionary::new();
dict.insert( dict.insert(

View File

@@ -6,10 +6,10 @@
use std::{ use std::{
env::{var, var_os}, env::{var, var_os},
ffi::OsString, ffi::OsString,
path::{Path, PathBuf}, path::PathBuf,
}; };
use crate::{error::NotarizeAuthError, Settings}; use crate::{error::NotarizeAuthError, Entitlements, Settings};
pub struct SignTarget { pub struct SignTarget {
pub path: PathBuf, pub path: PathBuf,
@@ -51,15 +51,20 @@ pub fn sign(
log::info!(action = "Signing"; "with identity \"{}\"", keychain.signing_identity()); log::info!(action = "Signing"; "with identity \"{}\"", keychain.signing_identity());
for target in targets { for target in targets {
let entitlements_path = if target.is_an_executable { let (entitlements_path, _temp_file) = match settings.macos().entitlements.as_ref() {
settings.macos().entitlements.as_ref().map(Path::new) Some(Entitlements::Path(path)) => (Some(path.to_owned()), None),
} else { Some(Entitlements::Plist(plist)) => {
None let mut temp_file = tempfile::NamedTempFile::new()?;
plist::to_writer_xml(temp_file.as_file_mut(), &plist)?;
(Some(temp_file.path().to_path_buf()), Some(temp_file))
}
None => (None, None),
}; };
keychain keychain
.sign( .sign(
&target.path, &target.path,
entitlements_path, entitlements_path.as_deref(),
target.is_an_executable && settings.macos().hardened_runtime, target.is_an_executable && settings.macos().hardened_runtime,
) )
.map_err(Box::new)?; .map_err(Box::new)?;

View File

@@ -360,12 +360,21 @@ pub struct MacOsSettings {
pub hardened_runtime: bool, pub hardened_runtime: bool,
/// Provider short name for notarization. /// Provider short name for notarization.
pub provider_short_name: Option<String>, pub provider_short_name: Option<String>,
/// Path to the entitlements.plist file. /// Path or contents of the entitlements.plist file.
pub entitlements: Option<String>, pub entitlements: Option<Entitlements>,
/// Path to the Info.plist file or raw plist value to merge with the bundle Info.plist. /// Path to the Info.plist file or raw plist value to merge with the bundle Info.plist.
pub info_plist: Option<PlistKind>, pub info_plist: Option<PlistKind>,
} }
/// Entitlements for macOS code signing.
#[derive(Debug, Clone)]
pub enum Entitlements {
/// Path to the entitlements.plist file.
Path(PathBuf),
/// Raw plist::Value.
Plist(plist::Value),
}
/// Plist format. /// Plist format.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum PlistKind { pub enum PlistKind {

View File

@@ -697,8 +697,10 @@ pub fn build_wix_app_installer(
.iter() .iter()
.flat_map(|p| &p.schemes) .flat_map(|p| &p.schemes)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !schemes.is_empty() {
data.insert("deep_link_protocols", to_json(schemes)); data.insert("deep_link_protocols", to_json(schemes));
} }
}
if let Some(path) = custom_template_path { if let Some(path) = custom_template_path {
handlebars handlebars

View File

@@ -495,8 +495,10 @@ fn build_nsis_app_installer(
.iter() .iter()
.flat_map(|p| &p.schemes) .flat_map(|p| &p.schemes)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !schemes.is_empty() {
data.insert("deep_link_protocols", to_json(schemes)); data.insert("deep_link_protocols", to_json(schemes));
} }
}
let silent_webview2_install = if let WebviewInstallMode::DownloadBootstrapper { silent } let silent_webview2_install = if let WebviewInstallMode::DownloadBootstrapper { silent }
| WebviewInstallMode::EmbedBootstrapper { silent } | WebviewInstallMode::EmbedBootstrapper { silent }

View File

@@ -858,7 +858,7 @@ impl AppSettings for RustAppSettings {
let mut settings = tauri_config_to_bundle_settings( let mut settings = tauri_config_to_bundle_settings(
self, self,
features, features,
config.identifier.clone(), config,
config.bundle.clone(), config.bundle.clone(),
updater_settings, updater_settings,
arch64bits, arch64bits,
@@ -1263,7 +1263,7 @@ pub fn get_profile_dir(options: &Options) -> &str {
fn tauri_config_to_bundle_settings( fn tauri_config_to_bundle_settings(
settings: &RustAppSettings, settings: &RustAppSettings,
features: &[String], features: &[String],
identifier: String, tauri_config: &Config,
config: crate::helpers::config::BundleConfig, config: crate::helpers::config::BundleConfig,
updater_config: Option<UpdaterSettings>, updater_config: Option<UpdaterSettings>,
arch64bits: bool, arch64bits: bool,
@@ -1386,8 +1386,59 @@ fn tauri_config_to_bundle_settings(
BundleResources::Map(map) => (None, Some(map)), BundleResources::Map(map) => (None, Some(map)),
}; };
#[cfg(target_os = "macos")]
let entitlements = if let Some(plugin_config) = tauri_config
.plugins
.0
.get("deep-link")
.and_then(|c| c.get("desktop").cloned())
{
let protocols: DesktopDeepLinks =
serde_json::from_value(plugin_config).context("failed to parse deep link plugin config")?;
let domains = match protocols {
DesktopDeepLinks::One(protocol) => protocol.domains,
DesktopDeepLinks::List(protocols) => protocols.into_iter().flat_map(|p| p.domains).collect(),
};
if domains.is_empty() {
config
.macos
.entitlements
.map(PathBuf::from)
.map(tauri_bundler::bundle::Entitlements::Path)
} else {
let mut app_links_entitlements = plist::Dictionary::new();
app_links_entitlements.insert(
"com.apple.developer.associated-domains".to_string(),
domains
.into_iter()
.map(|domain| format!("applinks:{domain}").into())
.collect::<Vec<_>>()
.into(),
);
let entitlements = if let Some(user_provided_entitlements) = config.macos.entitlements {
crate::helpers::plist::merge_plist(vec![
PathBuf::from(user_provided_entitlements).into(),
plist::Value::Dictionary(app_links_entitlements).into(),
])?
} else {
app_links_entitlements.into()
};
Some(tauri_bundler::bundle::Entitlements::Plist(entitlements))
}
} else {
config
.macos
.entitlements
.map(PathBuf::from)
.map(tauri_bundler::bundle::Entitlements::Path)
};
#[cfg(not(target_os = "macos"))]
let entitlements = None;
Ok(BundleSettings { Ok(BundleSettings {
identifier: Some(identifier), identifier: Some(tauri_config.identifier.clone()),
publisher: config.publisher, publisher: config.publisher,
homepage: config.homepage, homepage: config.homepage,
icon: Some(config.icon), icon: Some(config.icon),
@@ -1487,7 +1538,7 @@ fn tauri_config_to_bundle_settings(
skip_stapling: false, skip_stapling: false,
hardened_runtime: config.macos.hardened_runtime, hardened_runtime: config.macos.hardened_runtime,
provider_short_name, provider_short_name,
entitlements: config.macos.entitlements, entitlements,
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
info_plist: None, info_plist: None,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -23,7 +23,7 @@ wry = { version = "0.53.2", default-features = false, features = [
"os-webview", "os-webview",
"linux-body", "linux-body",
] } ] }
tao = { version = "0.34.1", default-features = false, features = ["rwh_06"] } tao = { version = "0.34.2", default-features = false, features = ["rwh_06"] }
tauri-runtime = { version = "2.8.0", path = "../tauri-runtime" } tauri-runtime = { version = "2.8.0", path = "../tauri-runtime" }
tauri-utils = { version = "2.7.0", path = "../tauri-utils" } tauri-utils = { version = "2.7.0", path = "../tauri-utils" }
raw-window-handle = "0.6" raw-window-handle = "0.6"

View File

@@ -1198,7 +1198,17 @@ pub struct FileAssociation {
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DeepLinkProtocol { pub struct DeepLinkProtocol {
/// URL schemes to associate with this app without `://`. For example `my-app` /// URL schemes to associate with this app without `://`. For example `my-app`
#[serde(default)]
pub schemes: Vec<String>, pub schemes: Vec<String>,
/// Domains to associate with this app. For example `example.com`.
/// Currently only supported on macOS, translating to an [universal app link].
///
/// Note that universal app links require signed apps with a provisioning profile to work.
/// You can accomplish that by including the `embedded.provisionprofile` file in the `macOS > files` option.
///
/// [universal app link]: https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app
#[serde(default)]
pub domains: Vec<String>,
/// The protocol name. **macOS-only** and maps to `CFBundleTypeName`. Defaults to `<bundle-id>.<schemes[0]>` /// The protocol name. **macOS-only** and maps to `CFBundleTypeName`. Defaults to `<bundle-id>.<schemes[0]>`
pub name: Option<String>, pub name: Option<String>,
/// The app's role for these schemes. **macOS-only** and maps to `CFBundleTypeRole`. /// The app's role for these schemes. **macOS-only** and maps to `CFBundleTypeRole`.