diff --git a/.changes/deep-link-ios-associated-domains.md b/.changes/deep-link-ios-associated-domains.md
new file mode 100644
index 000000000..b92bfbdc4
--- /dev/null
+++ b/.changes/deep-link-ios-associated-domains.md
@@ -0,0 +1,6 @@
+---
+"deep-link": patch
+"deep-link-js": patch
+---
+
+The iOS associated domains entitlement is no longer overwritten on build: the plugin now only replaces its `applinks:` entries and keeps the other entries your app adds, such as `webcredentials:` for passkeys.
diff --git a/.changes/fix-deep-link-ios-url-types.md b/.changes/fix-deep-link-ios-url-types.md
new file mode 100644
index 000000000..10be3600a
--- /dev/null
+++ b/.changes/fix-deep-link-ios-url-types.md
@@ -0,0 +1,8 @@
+---
+"deep-link": patch
+"deep-link-js": patch
+---
+
+Fixed the iOS `CFBundleURLTypes` generation: every configured custom URL scheme is now registered, including schemes listed next to `https`/`http` in the same `mobile` entry, and web link entries no longer produce an empty URL type.
+
+The generated iOS `CFBundleURLName` is now `$(PRODUCT_BUNDLE_IDENTIFIER).{scheme}`.
diff --git a/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index 8ebe7c4b7..96268f74e 100644
--- a/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/examples/api/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -42,6 +42,35 @@
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Read and write to NFC tags for testing
NSLocationWhenInUseUsageDescription
Read the device location for testing
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ tauri-plugins-e2e
+
+ CFBundleURLName
+ $(PRODUCT_BUNDLE_IDENTIFIER).tauri-plugins-e2e
+
+
+ CFBundleURLSchemes
+
+ tauri-plugins-e2e-mixed
+
+ CFBundleURLName
+ $(PRODUCT_BUNDLE_IDENTIFIER).tauri-plugins-e2e-mixed
+
+
\ No newline at end of file
diff --git a/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements b/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements
index 0c67376eb..21e2f8d83 100644
--- a/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements
+++ b/examples/api/src-tauri/gen/apple/api_iOS/api_iOS.entitlements
@@ -2,4 +2,4 @@
-
+
\ No newline at end of file
diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json
index e30e3364a..72c333953 100644
--- a/examples/api/src-tauri/tauri.conf.json
+++ b/examples/api/src-tauri/tauri.conf.json
@@ -75,6 +75,16 @@
"shell": {
"open": true
},
+ "deep-link": {
+ "mobile": [
+ { "scheme": ["tauri-plugins-e2e"] },
+ {
+ "scheme": ["https", "tauri-plugins-e2e-mixed"],
+ "host": "e2e.tauri.app",
+ "appLink": false
+ }
+ ]
+ },
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5QzMxNjYwNTM5OEUwNTgKUldSWTRKaFRZQmJER1h4d1ZMYVA3dnluSjdpN2RmMldJR09hUFFlZDY0SlFqckkvRUJhZDJVZXAK",
"dangerousInsecureTransportProtocol": true,
diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md
index 44463ccc3..dcb990afd 100644
--- a/packages/api-e2e/README.md
+++ b/packages/api-e2e/README.md
@@ -64,32 +64,32 @@ The example only registers each plugin on the platforms it supports, so the suit
split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are
skipped on desktop, and the rest run everywhere with the odd test gated.
-| Plugin | Coverage | On mobile |
-| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
-| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
-| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
-| `clipboard-manager` | text, secret, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
-| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
-| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
-| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
-| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
-| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
-| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
-| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
-| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
-| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
-| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
-| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
-| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
-| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
-| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
-| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
-| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
-| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
-| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
-| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | Android only; skipped on desktop. |
+| Plugin | Coverage | On mobile |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
+| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
+| `clipboard-manager` | text, secret, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
+| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Custom-scheme URLs opened through the OS (`am start` / `simctl openurl`) reach `onOpenUrl` and `getCurrent`, using the example's `plugins.deep-link.mobile` schemes; an unregistered scheme does not. Runtime registration is unsupported. |
+| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
+| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
+| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
+| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
+| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
+| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
+| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
+| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
+| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
+| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
+| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
+| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
+| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
+| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
+| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
+| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
+| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
+| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | Android only; skipped on desktop. |
| `geolocation` | `getCurrentPosition`, `watchPosition` (several at once) and `clearWatch` against a location simulated from the host, the granted permission state, and the rejections with location services off (Android). |
-| mobile-only plugins | `barcode-scanner`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
+| mobile-only plugins | `barcode-scanner`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
The notification permission specs are desktop-only: a mobile app starts out ungranted and
`requestPermission` puts up a system dialog the session would then block on. The rest of the
diff --git a/packages/api-e2e/test/helpers/device.ts b/packages/api-e2e/test/helpers/device.ts
new file mode 100644
index 000000000..fa4557ae9
--- /dev/null
+++ b/packages/api-e2e/test/helpers/device.ts
@@ -0,0 +1,30 @@
+// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+// Host-side commands that drive the emulator/simulator under test directly,
+// for what the app cannot do to itself (simulate a location, open a URL
+// through the OS, ...).
+
+import path from 'node:path'
+
+/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */
+export function adbArgs(args: string[]): [string, string[]] {
+ const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
+ const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
+ const device = process.env.E2E_ANDROID_DEVICE
+ ? ['-s', process.env.E2E_ANDROID_DEVICE]
+ : []
+ return [binary, [...device, ...args]]
+}
+
+/** `xcrun simctl ` on the simulator under test. */
+export function simctlArgs([command, ...args]: string[]): [string, string[]] {
+ const simulator = process.env.E2E_IOS_UDID
+ if (!simulator) {
+ throw new Error(
+ 'E2E_IOS_UDID is not set — run the suite via wdio.ios.conf.ts'
+ )
+ }
+ return ['xcrun', ['simctl', command, simulator, ...args]]
+}
diff --git a/packages/api-e2e/test/specs/deep-link.spec.ts b/packages/api-e2e/test/specs/deep-link.spec.ts
index 1deb711b5..4e153a1af 100644
--- a/packages/api-e2e/test/specs/deep-link.spec.ts
+++ b/packages/api-e2e/test/specs/deep-link.spec.ts
@@ -2,13 +2,28 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
-import { expect } from '@wdio/globals'
-import { tauri, tauriError, describePlugin, itOn } from '../helpers/index.js'
+import { spawnSync } from 'node:child_process'
+import { browser, expect } from '@wdio/globals'
+import {
+ tauri,
+ tauriError,
+ describePlugin,
+ eventually,
+ itOn,
+ platform,
+ type Platform
+} from '../helpers/index.js'
+import { adbArgs, simctlArgs } from '../helpers/device.js'
-// The suite launches the app without a URL, so there is no current deep link,
-// and it cannot open one through the OS either. `onOpenUrl` is exercised by
+// The suite launches the app without a URL, so there is no current deep link.
+// On desktop it cannot open one through the OS, so `onOpenUrl` is exercised by
// emitting the event the plugin itself emits when the app is opened with one.
//
+// On mobile the URL is opened through the OS from the host (`am start` on
+// Android, `simctl openurl` on iOS), which only reaches the app if the build
+// script registered its scheme in the AndroidManifest.xml / Info.plist from the
+// example's `plugins.deep-link.mobile` config.
+//
// Registering a scheme at runtime is only implemented on Linux (a `.desktop`
// handler plus `xdg-mime`) and Windows (the registry); macOS and mobile read
// the schemes from the bundle and report the runtime APIs as unsupported.
@@ -16,6 +31,90 @@ import { tauri, tauriError, describePlugin, itOn } from '../helpers/index.js'
/** A scheme no real app handles, so registering it cannot clobber anything. */
const scheme = 'tauri-plugins-e2e'
+/**
+ * Configured next to `https` in one entry of the example's
+ * `plugins.deep-link.mobile`, which iOS must still register as a URL scheme.
+ */
+const mixedScheme = 'tauri-plugins-e2e-mixed'
+
+const appId = 'com.tauri.api'
+
+/**
+ * Opens `url` through the OS, as another app would. Resolves with whether the
+ * OS found an app to hand it to.
+ */
+function openThroughOs(url: string): { opened: boolean; output: string } {
+ const [binary, args] =
+ platform === 'android'
+ ? adbArgs([
+ 'shell',
+ 'am',
+ 'start',
+ '-W',
+ '-a',
+ 'android.intent.action.VIEW',
+ // `adb shell` runs the command through the device's shell.
+ '-d',
+ `'${url}'`,
+ appId
+ ])
+ : simctlArgs(['openurl', url])
+ const result = spawnSync(binary, args, { encoding: 'utf8', timeout: 30_000 })
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
+ // `am start` exits with 0 even when no activity matches the intent.
+ const opened = result.status === 0 && !/^Error/m.test(output)
+ return { opened, output }
+}
+
+/**
+ * Answers the "Open in “Tauri API”?" confirmation iOS shows before it hands a
+ * custom-scheme URL from outside the app (here `simctl openurl`) to the app.
+ * The alert belongs to SpringBoard, which WebDriverAgent only searches from the
+ * native context once it is made the active application.
+ */
+async function acceptIosOpenPrompt(): Promise {
+ const webview = (await browser.getAppiumContext()) as string
+ await browser.switchAppiumContext('NATIVE_APP')
+ await browser.updateSettings({
+ defaultActiveApplication: 'com.apple.springboard'
+ })
+ try {
+ const open = browser.$(
+ '-ios predicate string:type == "XCUIElementTypeButton" AND label == "Open"'
+ )
+ // Not shown again once the user picked "Open" for the same source.
+ const shown = await open
+ .waitForExist({ timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false)
+ if (shown) {
+ await open.click()
+ }
+ } finally {
+ await browser.updateSettings({ defaultActiveApplication: 'auto' })
+ await browser.switchAppiumContext(webview)
+ }
+}
+
+/** Records every `onOpenUrl` delivery into `window.__deepLinkE2E`. */
+async function recordOpenedUrls(): Promise {
+ await tauri(async (api) => {
+ const w = window as unknown as { __deepLinkE2E?: string[][] }
+ if (w.__deepLinkE2E) {
+ return
+ }
+ w.__deepLinkE2E = []
+ await api.deepLink.onOpenUrl((urls) => w.__deepLinkE2E!.push(urls))
+ })
+}
+
+async function openedUrls(): Promise {
+ return tauri(
+ () =>
+ (window as unknown as { __deepLinkE2E?: string[][] }).__deepLinkE2E ?? []
+ )
+}
+
describePlugin('deep-link', () => {
it('getCurrent is null when the app was not opened with a URL', async () => {
expect(await tauri((api) => api.deepLink.getCurrent())).toBeNull()
@@ -89,4 +188,54 @@ describePlugin('deep-link', () => {
}
}
)
+
+ for (const { url, platforms } of [
+ { url: `${scheme}://open/path?query=1`, platforms: ['android', 'ios'] },
+ {
+ url: `${mixedScheme}://e2e.tauri.app/path`,
+ platforms: ['android', 'ios']
+ },
+ // iOS only routes https URLs to an app through universal links, which need
+ // the associated domains entitlement of a signed build (the suite's
+ // simulator build is unsigned) and a verified apple-app-site-association.
+ // `am start` with the package skips Android's App Links verification but
+ // still has to match the https intent filter.
+ { url: 'https://e2e.tauri.app/path', platforms: ['android'] }
+ ] satisfies { url: string; platforms: Platform[] }[]) {
+ itOn(
+ platforms,
+ `delivers ${url.split('://')[0]}:// URLs opened through the OS`,
+ async () => {
+ await recordOpenedUrls()
+ const before = (await openedUrls()).length
+
+ const { opened, output } = openThroughOs(url)
+ expect({ opened, output }).toEqual({ opened: true, output })
+ if (platform === 'ios') {
+ await acceptIosOpenPrompt()
+ }
+
+ await eventually(
+ async () => {
+ expect((await openedUrls()).slice(before)).toEqual([[url]])
+ expect(await tauri((api) => api.deepLink.getCurrent())).toEqual([
+ url
+ ])
+ },
+ { timeout: 15_000 }
+ )
+ }
+ )
+ }
+
+ // Guards the test above: if the OS handed every URL to the app, it would
+ // pass without any scheme being registered.
+ itOn(
+ ['android', 'ios'],
+ 'the OS does not route an unregistered scheme to the app',
+ () => {
+ const { opened } = openThroughOs(`${scheme}-unregistered://open`)
+ expect(opened).toBe(false)
+ }
+ )
})
diff --git a/packages/api-e2e/test/specs/geolocation.spec.ts b/packages/api-e2e/test/specs/geolocation.spec.ts
index 791efb628..d4af87ff4 100644
--- a/packages/api-e2e/test/specs/geolocation.spec.ts
+++ b/packages/api-e2e/test/specs/geolocation.spec.ts
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
-import path from 'node:path'
import { execFile, spawnSync } from 'node:child_process'
import { expect } from '@wdio/globals'
import {
@@ -13,6 +12,7 @@ import {
itOn,
platform
} from '../helpers/index.js'
+import { adbArgs, simctlArgs } from '../helpers/device.js'
// The location is simulated from the host: `adb emu geo fix` on the Android
// emulator and `simctl location` on the iOS simulator. Neither prompts for
@@ -35,16 +35,6 @@ const OPERA_HOUSE: Point = { latitude: -33.8568, longitude: 151.2153 }
const OPTIONS = { enableHighAccuracy: true, timeout: 1000, maximumAge: 0 }
-/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */
-function adbArgs(args: string[]): [string, string[]] {
- const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
- const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
- const device = process.env.E2E_ANDROID_DEVICE
- ? ['-s', process.env.E2E_ANDROID_DEVICE]
- : []
- return [binary, [...device, ...args]]
-}
-
function adb(args: string[]): void {
const [binary, fullArgs] = adbArgs(args)
const result = spawnSync(binary, fullArgs, {
@@ -71,17 +61,6 @@ function locationCommand(point: Point): [string, string[]] {
return simctlArgs(['location', 'set', `${point.latitude},${point.longitude}`])
}
-/** `xcrun simctl ` on the simulator under test. */
-function simctlArgs([command, ...args]: string[]): [string, string[]] {
- const simulator = process.env.E2E_IOS_UDID
- if (!simulator) {
- throw new Error(
- 'E2E_IOS_UDID is not set — run the suite via wdio.ios.conf.ts'
- )
- }
- return ['xcrun', ['simctl', command, simulator, ...args]]
-}
-
/**
* Grants the app location access on the simulator. locationd forgets an app's
* authorization whenever it is reinstalled, which Appium does at the start of
diff --git a/plugins/deep-link/README.md b/plugins/deep-link/README.md
index 1d8c881b9..3f42afd03 100644
--- a/plugins/deep-link/README.md
+++ b/plugins/deep-link/README.md
@@ -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/
```
+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:` 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 `.
diff --git a/plugins/deep-link/build.rs b/plugins/deep-link/build.rs
index a08a6e9b5..3e53cabe9 100644
--- a/plugins/deep-link/build.rs
+++ b/plugins/deep-link/build.rs
@@ -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::>()
+ })
+ .unwrap_or_default();
- if !has_app_links {
- tauri_plugin::mobile::update_entitlements(|entitlements| {
+ let associated_domains = config.ios_associated_domains(¤t);
+
+ 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::>()
.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::>();
+ 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::>();
-
+ 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::>()
.into(),
);
- dict.insert(
- "CFBundleURLName".into(),
- domain.scheme[0].clone().into(),
- );
+ dict.insert("CFBundleURLName".into(), url_type.name.into());
plist::Value::Dictionary(dict)
})
.collect::>()
.into(),
);
- })
- .expect("failed to update Info.plist");
- }
+ }
+ })
+ .expect("failed to update Info.plist");
}
}
}
diff --git a/plugins/deep-link/examples/app/src-tauri/apple-app-site-association b/plugins/deep-link/examples/app/src-tauri/apple-app-site-association
index f94da1be3..2d7ae2951 100644
--- a/plugins/deep-link/examples/app/src-tauri/apple-app-site-association
+++ b/plugins/deep-link/examples/app/src-tauri/apple-app-site-association
@@ -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 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/plugins/deep-link/examples/app/src-tauri/server.js b/plugins/deep-link/examples/app/src-tauri/server.js
index 24a6ac6f8..d7d9ee4d3 100644
--- a/plugins/deep-link/examples/app/src-tauri/server.js
+++ b/plugins/deep-link/examples/app/src-tauri/server.js
@@ -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')
}
)
diff --git a/plugins/deep-link/src/config.rs b/plugins/deep-link/src/config.rs
index e4465719b..b76e1a57b 100644
--- a/plugins/deep-link/src/config.rs
+++ b/plugins/deep-link/src/config.rs
@@ -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- {
+ 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,
+}
+
+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 {
+ 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 {
+ self.mobile
+ .iter()
+ .filter_map(|domain| {
+ let schemes = domain.custom_schemes().cloned().collect::>();
+ 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(¤t),
+ 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()
+ );
+ }
+}