mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
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:
1 parent
bbbdd9dd54
commit
c92bb67d7a
15 files changed
+476
-104
No files matched your search
@@ -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.
|
||||
@@ -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}`.
|
||||
@@ -42,6 +42,35 @@
|
||||
android:name="android.nfc.action.TECH_DISCOVERED"
|
||||
android:resource="@xml/nfc_tech_filter" />
|
||||
<!-- NFC PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<intent-filter >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<!-- ChromeOS ARC++ uses a different action for deep links -->
|
||||
<action android:name="org.chromium.arc.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="tauri-plugins-e2e" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</intent-filter>
|
||||
<intent-filter >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<!-- ChromeOS ARC++ uses a different action for deep links -->
|
||||
<action android:name="org.chromium.arc.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:scheme="tauri-plugins-e2e-mixed" />
|
||||
<data android:host="e2e.tauri.app" />
|
||||
|
||||
|
||||
|
||||
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -50,5 +50,24 @@
|
||||
<string>Read and write to NFC tags for testing</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Read the device location for testing</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>tauri-plugins-e2e</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).tauri-plugins-e2e</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>tauri-plugins-e2e-mixed</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).tauri-plugins-e2e-mixed</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,4 +2,4 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
</plist>
|
||||
@@ -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,
|
||||
|
||||
+25
-25
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(¤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::<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')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(¤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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user