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

+25 -25
View File
@@ -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
+30
View File
@@ -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 <command> <simulator> <args>` 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]]
}
+153 -4
View File
@@ -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<void> {
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<void> {
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<string[][]> {
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)
}
)
})
@@ -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 <command> <simulator> <args>` 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