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() + ); + } +}