fix(deep-link): filter out web links instead of app links in deep link domains (#3382)

* fix(deep-link): filter out web links instead of app links in deep link domains

This fixes a bug where on ios, deeplinks just don't work because the final Info.plist file doesn't have them.
And also used a better id for CFBundleURLName

Fixes regression from 2804803949

* feat(deep-link): add `webcredentials:` for iOS entitlements file

* chore: added changelog for deep-link patch

* add tests

* remove webcredentials

* -js

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Amjad AlsharafiandLucas Nogueira authored and GitHub committed 2026-10-16 15:28:02 -03:00
1 parent bbbdd9dd54
commit c92bb67d7a
15 files changed
+476 -104

No files matched your search

+2
View File
@@ -94,6 +94,8 @@ To verify if your domain has been properly configured to expose the app associat
curl -v https://app-site-association.cdn-apple.com/a/v1/<host>
```
The plugin only manages the `applinks:` entries of the `com.apple.developer.associated-domains` entitlement. Other entries you add to your app's entitlements file, such as `webcredentials:<host>` for [Password AutoFill or passkeys](https://developer.apple.com/documentation/xcode/supporting-associated-domains), are kept.
**The apple-app-site-association file must be served over HTTPS and the response must include the `Content-Type: application/json` header.**
To quickly open an app link on the iOS simulator you can execute `xcrun simctl openurl booted <url>`.
+37 -48
View File
@@ -115,77 +115,66 @@ fn main() {
// generated for explicit app links and not
// other deep links because then they
// are just going to complain and not be built or signed
let has_app_links = config.mobile.iter().any(|d| d.is_app_link());
tauri_plugin::mobile::update_entitlements(|entitlements| {
let current = entitlements
.get("com.apple.developer.associated-domains")
.and_then(|domains| domains.as_array())
.map(|domains| {
domains
.iter()
.filter_map(|domain| domain.as_string())
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !has_app_links {
tauri_plugin::mobile::update_entitlements(|entitlements| {
let associated_domains = config.ios_associated_domains(&current);
if associated_domains.is_empty() {
entitlements.remove("com.apple.developer.associated-domains");
})
.expect("failed to update entitlements");
} else {
tauri_plugin::mobile::update_entitlements(|entitlements| {
} else {
entitlements.insert(
"com.apple.developer.associated-domains".into(),
config
.mobile
.iter()
.filter(|d| d.is_app_link())
.filter_map(|d| d.host.as_ref())
.map(|host| format!("applinks:{}", host).into())
associated_domains
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into(),
);
})
.expect("failed to update entitlements");
}
}
})
.expect("failed to update entitlements");
let deep_link_domains = config
.mobile
.iter()
.filter(|domain| !domain.is_app_link())
.collect::<Vec<_>>();
let url_types = config.ios_url_types();
if deep_link_domains.is_empty() {
tauri_plugin::mobile::update_info_plist(|info_plist| {
tauri_plugin::mobile::update_info_plist(|info_plist| {
if url_types.is_empty() {
info_plist.remove("CFBundleURLTypes");
})
.expect("failed to update Info.plist");
} else {
tauri_plugin::mobile::update_info_plist(|info_plist| {
} else {
info_plist.insert(
"CFBundleURLTypes".into(),
deep_link_domains
.iter()
.map(|domain| {
let schemes = domain
.scheme
.iter()
.filter(|scheme| {
scheme.as_str() != "https" && scheme.as_str() != "http"
})
.collect::<Vec<_>>();
url_types
.into_iter()
.map(|url_type| {
let mut dict = plist::Dictionary::new();
dict.insert(
"CFBundleURLSchemes".into(),
schemes
.iter()
.map(|s| s.to_string().into())
url_type
.schemes
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into(),
);
dict.insert(
"CFBundleURLName".into(),
domain.scheme[0].clone().into(),
);
dict.insert("CFBundleURLName".into(), url_type.name.into());
plist::Value::Dictionary(dict)
})
.collect::<Vec<_>>()
.into(),
);
})
.expect("failed to update Info.plist");
}
}
})
.expect("failed to update Info.plist");
}
}
}
@@ -1,8 +1,8 @@
{
"applinks": {
"details": [
"details": [
{
"appIDs": [ "DEVELOPMENT_TEAM_ID_HERE.com.tauri.deep-link-example" ],
"appIDs": ["DEVELOPMENT_TEAM_ID_HERE.com.tauri.deep-link-example"],
"components": [
{
"/": "/open/*",
@@ -12,4 +12,4 @@
}
]
}
}
}
@@ -17,7 +17,7 @@ http
fs.readFile(
path.resolve(__dirname, 'apple-app-site-association'),
function (_error, content) {
response.writeHead(200)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(content, 'utf-8')
}
)
+151
View File
@@ -37,6 +37,14 @@ impl AssociatedDomain {
.unwrap_or_else(|| self.is_web_link() && self.host.is_some())
}
/// The custom URL schemes of this domain, i.e. every scheme but http and https.
///
/// A domain may list both (e.g. `["https", "myapp"]`), so this is not the same as `!is_web_link()`.
#[allow(dead_code)] // Used in build.rs
pub fn custom_schemes(&self) -> impl Iterator<Item = &String> {
self.scheme.iter().filter(|s| *s != "https" && *s != "http")
}
pub fn validate(&self) -> Result<(), String> {
// Rule 1: All web links require a host.
if self.is_web_link() && self.host.is_none() {
@@ -92,6 +100,54 @@ pub struct Config {
pub desktop: DesktopProtocol,
}
/// An entry of the iOS `CFBundleURLTypes` Info.plist key.
#[allow(dead_code)] // Used in build.rs
#[derive(Debug, PartialEq, Eq)]
pub struct IosUrlType {
/// `CFBundleURLName`
pub name: String,
/// `CFBundleURLSchemes`
pub schemes: Vec<String>,
}
impl Config {
/// The `com.apple.developer.associated-domains` entitlement values, given its current ones.
///
/// The plugin only manages the `applinks:` entries: they are replaced by the configured app links,
/// and every other entry (e.g. `webcredentials:` for passkeys) the app added is kept.
#[allow(dead_code)] // Used in build.rs
pub fn ios_associated_domains(&self, current: &[String]) -> Vec<String> {
self.mobile
.iter()
.filter(|d| d.is_app_link())
.filter_map(|d| d.host.as_ref())
.map(|host| format!("applinks:{host}"))
.chain(
current
.iter()
.filter(|entry| !entry.starts_with("applinks:"))
.cloned(),
)
.collect()
}
/// The `CFBundleURLTypes` entries for the configured custom URL schemes.
///
/// http and https are handled through app links (associated domains) on iOS,
/// so they are never registered as URL schemes.
#[allow(dead_code)] // Used in build.rs
pub fn ios_url_types(&self) -> Vec<IosUrlType> {
self.mobile
.iter()
.filter_map(|domain| {
let schemes = domain.custom_schemes().cloned().collect::<Vec<_>>();
let name = format!("$(PRODUCT_BUNDLE_IDENTIFIER).{}", schemes.first()?);
Some(IosUrlType { name, schemes })
})
.collect()
}
}
#[derive(Deserialize, Clone)]
#[serde(untagged)]
#[allow(unused)] // Used in tauri-bundler
@@ -128,3 +184,98 @@ impl DesktopProtocol {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(mobile: serde_json::Value) -> Config {
serde_json::from_value(serde_json::json!({ "mobile": mobile })).unwrap()
}
#[test]
fn ios_url_types_only_register_custom_schemes() {
let config = config(serde_json::json!([
{ "scheme": ["myapp"] },
{ "scheme": ["https"], "host": "example.com" },
{ "scheme": ["https"], "host": "web.example.com", "appLink": false },
{ "host": "default-schemes.example.com" },
{ "scheme": ["https", "mixed"], "host": "mixed.example.com" },
{ "scheme": ["http", "mixed-web", "mixed-web2"], "host": "mixed-web.example.com", "appLink": false },
{ "scheme": [] },
]));
assert_eq!(
config.ios_url_types(),
vec![
IosUrlType {
name: "$(PRODUCT_BUNDLE_IDENTIFIER).myapp".into(),
schemes: vec!["myapp".into()],
},
IosUrlType {
name: "$(PRODUCT_BUNDLE_IDENTIFIER).mixed".into(),
schemes: vec!["mixed".into()],
},
IosUrlType {
name: "$(PRODUCT_BUNDLE_IDENTIFIER).mixed-web".into(),
schemes: vec!["mixed-web".into(), "mixed-web2".into()],
},
]
);
}
#[test]
fn ios_associated_domains_only_include_app_links() {
let config = config(serde_json::json!([
{ "scheme": ["myapp"] },
{ "scheme": ["https"], "host": "example.com" },
{ "scheme": ["https"], "host": "web.example.com", "appLink": false },
{ "scheme": ["https", "mixed"], "host": "mixed.example.com" },
]));
assert_eq!(
config.ios_associated_domains(&[]),
vec!["applinks:example.com", "applinks:mixed.example.com"]
);
}
#[test]
fn ios_associated_domains_replace_app_links_and_keep_other_entries() {
let config = config(serde_json::json!([
{ "host": "example.com" },
{ "host": "new.example.com" },
]));
let current = [
"applinks:example.com".to_string(),
"webcredentials:example.com".to_string(),
"applinks:removed.example.com".to_string(),
"activitycontinuation:example.com".to_string(),
];
assert_eq!(
config.ios_associated_domains(&current),
vec![
"applinks:example.com",
"applinks:new.example.com",
"webcredentials:example.com",
"activitycontinuation:example.com",
]
);
}
#[test]
fn ios_associated_domains_keep_other_entries_without_app_links() {
let config = config(serde_json::json!([{ "scheme": ["myapp"] }]));
assert_eq!(
config.ios_associated_domains(&["webcredentials:example.com".to_string()]),
vec!["webcredentials:example.com"]
);
assert!(
config
.ios_associated_domains(&["applinks:example.com".to_string()])
.is_empty()
);
}
}