tests(e2e): improve coverage

This commit is contained in:
Lucas Nogueira
2026-09-22 21:54:21 -03:00
parent 684feb2510
commit c8c373b48b
50 changed files with 1893 additions and 183 deletions
@@ -0,0 +1,11 @@
---
notification: patch
notification-js: patch
---
Fixed broken mobile APIs:
- `channels()` invoked the `listChannels` command, which the `allow-list-channels` permission does not match; it now invokes `list_channels`.
- Unregistering an `onNotificationReceived` or `onAction` listener invokes the `remove_listener` command, which had no permission. It is now declared and part of the `default` permission set (`allow-remove-listener`).
- `cancelAll()` was rejected on Android and iOS, which required the list of notifications to cancel. It now cancels every pending notification.
- `active()` resolved with `{ values: [...] }` instead of an array on Android.
@@ -130,6 +130,9 @@ jobs:
working-directory: ./examples/api
env:
NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
# a debug build without debug info, which would otherwise grow the APK
# past what the emulator can install (see androidBuildEnv in wdio.mobile.ts)
CARGO_PROFILE_DEV_DEBUG: '0'
run: pnpm tauri android build --debug --apk --target $E2E_ANDROID_TARGET
- name: save gradle cache
+3 -1
View File
@@ -79,7 +79,9 @@ jobs:
librsvg2-dev \
dbus \
xvfb \
fluxbox
fluxbox \
desktop-file-utils \
xdg-utils
# msedgedriver has to match the installed WebView2 runtime, so msedgedriver-tool
# downloads the right version at run time. The tool itself is built from a pinned git
Generated
+88 -52
View File
@@ -215,10 +215,12 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-automation",
"tauri-plugin-autostart",
"tauri-plugin-barcode-scanner",
"tauri-plugin-biometric",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-geolocation",
@@ -230,11 +232,15 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-positioner",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-sql",
"tauri-plugin-store",
"tauri-plugin-stronghold",
"tauri-plugin-updater",
"tauri-plugin-upload",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
"time",
"tiny_http",
@@ -298,7 +304,7 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
@@ -1554,7 +1560,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.0",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1597,7 +1603,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading 0.7.4",
"libloading 0.8.9",
]
[[package]]
@@ -3509,7 +3515,7 @@ checksum = "0b95dfb34071d1592b45622bf93e315e3a72d414b6782aca9a015c12bec367ef"
dependencies = [
"cc",
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"time",
]
@@ -3645,12 +3651,12 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.12",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3900,9 +3906,9 @@ dependencies = [
[[package]]
name = "objc2-app-kit"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.2",
@@ -3913,49 +3919,53 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-foundation 0.3.0",
"objc2-quartz-core 0.3.0",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.9.0",
"dispatch2",
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.9.0",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
@@ -3963,12 +3973,12 @@ dependencies = [
[[package]]
name = "objc2-core-image"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -3978,7 +3988,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d31f4c5b5192304996badc466aeadffe1411d73a9bbd3b18b6b2ee9d048b07bd"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
@@ -4010,9 +4045,9 @@ dependencies = [
[[package]]
name = "objc2-foundation"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.2",
@@ -4023,9 +4058,9 @@ dependencies = [
[[package]]
name = "objc2-io-surface"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
@@ -4053,7 +4088,7 @@ dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4071,14 +4106,14 @@ dependencies = [
[[package]]
name = "objc2-quartz-core"
version = "0.3.0"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4096,8 +4131,8 @@ dependencies = [
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-foundation 0.3.0",
"objc2-quartz-core 0.3.0",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"objc2-user-notifications",
]
@@ -4108,7 +4143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fe793adbf3b5e93686d48a05a7ed7ee53dfa65d106ced4805fae8969059b2"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4122,7 +4157,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
]
[[package]]
@@ -4266,7 +4301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"objc2-osa-kit",
"serde",
"serde_json",
@@ -5044,7 +5079,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
@@ -5218,7 +5253,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5275,7 +5310,7 @@ dependencies = [
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6297,7 +6332,7 @@ dependencies = [
"ndk-sys",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"once_cell",
"parking_lot",
@@ -6372,7 +6407,7 @@ dependencies = [
"muda",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"objc2-web-kit",
"percent-encoding",
@@ -6483,12 +6518,13 @@ dependencies = [
[[package]]
name = "tauri-plugin-automation"
version = "0.1.4"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "314d4ba88ce4a5e17c5f1e2dad990edb5956643b6e04a6d127d39aa64d0f2a7d"
checksum = "40015ce24bdb1204983f83ac37c5469d55028de1e7fc56cdd0a6a7e15a1b5375"
dependencies = [
"libloading 0.8.9",
"objc2-app-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
@@ -6604,7 +6640,7 @@ dependencies = [
"log",
"notify",
"notify-debouncer-full",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"percent-encoding",
"schemars",
"serde",
@@ -6701,7 +6737,7 @@ dependencies = [
"fern",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"serde",
"serde_json",
"serde_repr",
@@ -6752,7 +6788,7 @@ dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"open",
"schemars",
"serde",
@@ -7108,7 +7144,7 @@ dependencies = [
"getrandom 0.3.2",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7535,12 +7571,12 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"serde",
"thiserror 2.0.12",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8261,7 +8297,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"raw-window-handle",
"windows-sys 0.59.0",
"windows-version",
@@ -8840,7 +8876,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
+17
View File
@@ -32,6 +32,23 @@ license = "Apache-2.0 OR MIT"
rust-version = "1.77.2"
repository = "https://github.com/tauri-apps/plugins-workspace"
# Stronghold encrypts its snapshots with scrypt: unoptimized, saving or loading one
# takes ~45s instead of ~1s, which stalls the (debug) example app and its e2e suite.
[profile.dev.package.scrypt]
opt-level = 3
[profile.dev.package.salsa20]
opt-level = 3
[profile.dev.package.pbkdf2]
opt-level = 3
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.hmac]
opt-level = 3
[profile.dev.package.iota-crypto]
opt-level = 3
[profile.dev.package.rust-argon2]
opt-level = 3
# default to small, optimized release binaries
[profile.release]
panic = "abort"
+6
View File
@@ -11,10 +11,12 @@
},
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-autostart": "workspace:*",
"@tauri-apps/plugin-barcode-scanner": "workspace:*",
"@tauri-apps/plugin-biometric": "workspace:*",
"@tauri-apps/plugin-cli": "workspace:*",
"@tauri-apps/plugin-clipboard-manager": "workspace:*",
"@tauri-apps/plugin-deep-link": "workspace:*",
"@tauri-apps/plugin-dialog": "workspace:*",
"@tauri-apps/plugin-fs": "workspace:*",
"@tauri-apps/plugin-geolocation": "workspace:*",
@@ -25,11 +27,15 @@
"@tauri-apps/plugin-notification": "workspace:*",
"@tauri-apps/plugin-opener": "workspace:*",
"@tauri-apps/plugin-os": "workspace:*",
"@tauri-apps/plugin-positioner": "workspace:*",
"@tauri-apps/plugin-process": "workspace:*",
"@tauri-apps/plugin-shell": "workspace:*",
"@tauri-apps/plugin-sql": "workspace:*",
"@tauri-apps/plugin-store": "workspace:*",
"@tauri-apps/plugin-stronghold": "workspace:*",
"@tauri-apps/plugin-updater": "workspace:*",
"@tauri-apps/plugin-upload": "workspace:*",
"@tauri-apps/plugin-websocket": "workspace:*",
"@zerodevx/svelte-json-view": "2.0.0"
},
"devDependencies": {
+11 -1
View File
@@ -39,12 +39,18 @@ tauri-plugin-opener = { path = "../../../plugins/opener", version = "2.5.5" }
tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.6" }
tauri-plugin-store = { path = "../../../plugins/store", version = "2.4.5" }
tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" }
tauri-plugin-deep-link = { path = "../../../plugins/deep-link", version = "2.4.10" }
tauri-plugin-sql = { path = "../../../plugins/sql", version = "2.4.1", features = [
"sqlite",
] }
tauri-plugin-stronghold = { path = "../../../plugins/stronghold", version = "2.3.2" }
tauri-plugin-websocket = { path = "../../../plugins/websocket", version = "2.4.3" }
# WebDriver automation bridge, used by the plugins e2e suite (packages/api-e2e).
# Desktop-only and behind the off-by-default `automation` feature so it never ships in a
# regular build.
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-automation = { version = "0.1.4", optional = true }
tauri-plugin-automation = { version = "0.2.0", optional = true }
[dependencies.tauri]
workspace = true
@@ -61,8 +67,12 @@ features = [
]
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies]
tauri-plugin-autostart = { path = "../../../plugins/autostart", version = "2.5.1" }
tauri-plugin-cli = { path = "../../../plugins/cli", version = "2.4.1" }
tauri-plugin-global-shortcut = { path = "../../../plugins/global-shortcut", version = "2.3.2" }
tauri-plugin-positioner = { path = "../../../plugins/positioner", version = "2.3.4", features = [
"tray-icon",
] }
tauri-plugin-updater = { path = "../../../plugins/updater", version = "2.12.0" }
tauri-plugin-window-state = { path = "../../../plugins/window-state", version = "2.2.0" }
+12 -1
View File
@@ -132,6 +132,17 @@
"identifier": "opener:allow-open-path",
"allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }]
},
"upload:default"
"upload:default",
"deep-link:default",
"deep-link:allow-register",
"deep-link:allow-unregister",
"deep-link:allow-is-registered",
"sql:default",
"sql:allow-execute",
"stronghold:default",
"stronghold:allow-destroy",
"stronghold:allow-remove-secret",
"stronghold:allow-remove-store-record",
"websocket:default"
]
}
@@ -5,7 +5,9 @@
"windows": ["main"],
"platforms": ["linux", "macOS", "windows"],
"permissions": [
"autostart:default",
"cli:default",
"positioner:default",
"updater:default",
"global-shortcut:allow-unregister",
"global-shortcut:allow-register",
+30 -1
View File
@@ -9,7 +9,7 @@ mod tray;
use serde::Serialize;
use tauri::{
webview::{PageLoadEvent, WebviewWindowBuilder},
App, AppHandle, Emitter, Listener, RunEvent, WebviewUrl,
App, AppHandle, Emitter, Listener, Manager, RunEvent, WebviewUrl,
};
#[derive(Clone, Serialize)]
@@ -55,11 +55,40 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_websocket::init())
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations(
"sqlite:api.db",
vec![tauri_plugin_sql::Migration {
version: 1,
description: "create_todos_table",
sql: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0);",
kind: tauri_plugin_sql::MigrationKind::Up,
}],
)
.build(),
)
.setup(move |app| {
// the argon2 salt lives next to the snapshots the frontend creates
let local_data_dir = app.path().app_local_data_dir()?;
std::fs::create_dir_all(&local_data_dir)?;
app.handle().plugin(
tauri_plugin_stronghold::Builder::with_argon2(&local_data_dir.join("salt.txt"))
.build(),
)?;
#[cfg(desktop)]
{
// registered before the tray, whose events it tracks
app.handle().plugin(tauri_plugin_positioner::init())?;
tray::create_tray(app.handle())?;
app.handle().plugin(tauri_plugin_cli::init())?;
app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))?;
app.handle()
.plugin(tauri_plugin_global_shortcut::Builder::new().build())?;
app.handle()
+2
View File
@@ -107,6 +107,8 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
_ => {}
})
.on_tray_icon_event(|tray, event| {
// lets the positioner plugin place windows relative to the tray icon
tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event);
if let TrayIconEvent::Click {
button_state: MouseButtonState::Down,
button: MouseButton::Left,
+36
View File
@@ -33,6 +33,12 @@
import Geolocation from './views/Geolocation.svelte'
import Haptics from './views/Haptics.svelte'
import Nfc from './views/Nfc.svelte'
import Autostart from './views/Autostart.svelte'
import DeepLink from './views/DeepLink.svelte'
import Positioner from './views/Positioner.svelte'
import Sql from './views/Sql.svelte'
import Stronghold from './views/Stronghold.svelte'
import WebSocket from './views/WebSocket.svelte'
import TitleBar from './lib/TitleBar.svelte'
@@ -131,6 +137,36 @@
component: WebRTC,
icon: 'i-ph-broadcast'
},
{
label: 'WebSocket',
component: WebSocket,
icon: 'i-ph-plugs-connected'
},
{
label: 'SQL',
component: Sql,
icon: 'i-ph-database'
},
{
label: 'Stronghold',
component: Stronghold,
icon: 'i-ph-lock-key'
},
{
label: 'Deep link',
component: DeepLink,
icon: 'i-ph-link'
},
!isMobile && {
label: 'Autostart',
component: Autostart,
icon: 'i-ph-power'
},
!isMobile && {
label: 'Positioner',
component: Positioner,
icon: 'i-ph-arrows-out-cardinal'
},
isMobile && {
label: 'Scanner',
component: Scanner,
+30
View File
@@ -0,0 +1,30 @@
<script>
import { enable, disable, isEnabled } from '@tauri-apps/plugin-autostart'
import { onMount } from 'svelte'
export let onMessage
let enabled = false
async function refresh() {
enabled = await isEnabled()
}
function toggle() {
;(enabled ? disable() : enable())
.then(refresh)
.then(() => onMessage(`Autostart ${enabled ? 'enabled' : 'disabled'}`))
.catch(onMessage)
}
onMount(() => {
refresh().catch(onMessage)
})
</script>
<div class="flex flex-row gap-2 items-center">
<button class="btn" on:click={toggle}>
{enabled ? 'Disable' : 'Enable'} autostart
</button>
<span>Launching at login is {enabled ? 'enabled' : 'disabled'}</span>
</div>
+67
View File
@@ -0,0 +1,67 @@
<script>
import {
getCurrent,
onOpenUrl,
register,
unregister,
isRegistered
} from '@tauri-apps/plugin-deep-link'
import { onMount, onDestroy } from 'svelte'
export let onMessage
let current = null
let protocol = 'tauri-api'
let unlisten
onMount(async () => {
try {
current = await getCurrent()
unlisten = await onOpenUrl((urls) => {
current = urls
onMessage(`Opened with ${urls.join(', ')}`)
})
} catch (error) {
onMessage(error)
}
})
onDestroy(() => {
unlisten?.()
})
function registerProtocol() {
register(protocol)
.then(() => onMessage(`Registered ${protocol}://`))
.catch(onMessage)
}
function unregisterProtocol() {
unregister(protocol)
.then(() => onMessage(`Unregistered ${protocol}://`))
.catch(onMessage)
}
function checkProtocol() {
isRegistered(protocol)
.then((registered) =>
onMessage(
`${protocol}:// is ${registered ? '' : 'not '}handled by this app`
)
)
.catch(onMessage)
}
</script>
<div class="flex flex-col gap-2">
<div>
Current deep link: <code>{current ? current.join(', ') : 'none'}</code>
</div>
<div class="flex flex-row gap-2 items-center">
<input class="input grow" placeholder="Scheme" bind:value={protocol} />
<button class="btn" on:click={registerProtocol}>Register</button>
<button class="btn" on:click={unregisterProtocol}>Unregister</button>
<button class="btn" on:click={checkProtocol}>Is registered?</button>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
<script>
import {
moveWindow,
moveWindowConstrained,
Position
} from '@tauri-apps/plugin-positioner'
export let onMessage
// `Position` is a numeric enum, so it also maps the values back to their names
const positions = Object.keys(Position).filter((key) => isNaN(Number(key)))
let position = 'Center'
let constrained = false
function move() {
;(constrained ? moveWindowConstrained : moveWindow)(Position[position])
.then(() => onMessage(`Moved the window to ${position}`))
.catch(onMessage)
}
</script>
<div class="flex flex-col gap-2">
<p>
The tray positions only resolve once the tray icon has been clicked, which
reports its location to the plugin.
</p>
<div class="flex flex-row gap-2 items-center">
<select class="input" bind:value={position}>
{#each positions as name}
<option value={name}>{name}</option>
{/each}
</select>
<label>
<input type="checkbox" bind:checked={constrained} />
Constrain to the tray icon's monitor
</label>
<button class="btn" on:click={move}>Move window</button>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script>
import Database from '@tauri-apps/plugin-sql'
import { onMount } from 'svelte'
export let onMessage
// the `todos` table is created by the migration the app registers for this database
let db
let todos = []
let title = ''
async function refresh() {
todos = await db.select('SELECT * FROM todos ORDER BY id')
}
onMount(async () => {
try {
db = await Database.load('sqlite:api.db')
await refresh()
} catch (error) {
onMessage(error)
}
})
function add() {
db.execute('INSERT INTO todos (title) VALUES ($1)', [title])
.then((result) => {
onMessage(result)
title = ''
return refresh()
})
.catch(onMessage)
}
function toggle(todo) {
db.execute('UPDATE todos SET done = $1 WHERE id = $2', [
todo.done ? 0 : 1,
todo.id
])
.then(refresh)
.catch(onMessage)
}
function remove(todo) {
db.execute('DELETE FROM todos WHERE id = $1', [todo.id])
.then(refresh)
.catch(onMessage)
}
</script>
<div class="flex flex-col gap-2">
<form class="flex flex-row gap-2" on:submit|preventDefault={add}>
<input class="input grow" placeholder="New todo" bind:value={title} />
<button class="btn" type="submit" disabled={!db || !title}>Add</button>
</form>
{#each todos as todo (todo.id)}
<div class="flex flex-row gap-2 items-center">
<input
type="checkbox"
checked={todo.done === 1}
on:change={() => toggle(todo)}
/>
<span class="grow" class:line-through={todo.done === 1}>{todo.title}</span>
<button class="btn" on:click={() => remove(todo)}>Delete</button>
</div>
{/each}
</div>
+71
View File
@@ -0,0 +1,71 @@
<script>
import { Stronghold } from '@tauri-apps/plugin-stronghold'
import { appLocalDataDir, join } from '@tauri-apps/api/path'
export let onMessage
const clientName = 'api-example'
const storeKey = 'secret'
let password = 'password'
let stronghold
let store
let record = ''
async function load() {
try {
const path = await join(await appLocalDataDir(), 'api.stronghold')
stronghold = await Stronghold.load(path, password)
let client
try {
client = await stronghold.loadClient(clientName)
} catch {
client = await stronghold.createClient(clientName)
}
store = client.getStore()
const value = await store.get(storeKey)
record = value ? new TextDecoder().decode(value) : ''
onMessage('Stronghold loaded')
} catch (error) {
stronghold = null
onMessage(error)
}
}
async function save() {
try {
await store.insert(storeKey, Array.from(new TextEncoder().encode(record)))
await stronghold.save()
onMessage('Record saved')
} catch (error) {
onMessage(error)
}
}
async function unload() {
await stronghold.unload().catch(onMessage)
stronghold = null
store = null
record = ''
}
</script>
<div class="flex flex-col gap-2">
{#if stronghold}
<div class="flex flex-row gap-2">
<input class="input grow" placeholder="Secret" bind:value={record} />
<button class="btn" on:click={save}>Save</button>
<button class="btn" on:click={unload}>Lock</button>
</div>
{:else}
<form class="flex flex-row gap-2" on:submit|preventDefault={load}>
<input
class="input grow"
type="password"
placeholder="Password"
bind:value={password}
/>
<button class="btn" type="submit">Unlock</button>
</form>
{/if}
</div>
+55
View File
@@ -0,0 +1,55 @@
<script>
import WebSocket from '@tauri-apps/plugin-websocket'
import { onDestroy } from 'svelte'
export let onMessage
let url = 'wss://echo.websocket.org'
let message = ''
let ws
async function connect() {
try {
ws = await WebSocket.connect(url)
ws.addListener((received) => {
onMessage(received)
if (received.type === 'Close') {
ws = null
}
})
onMessage(`Connected to ${url}`)
} catch (error) {
onMessage(error)
}
}
function send() {
ws.send(message)
.then(() => (message = ''))
.catch(onMessage)
}
function disconnect() {
ws.disconnect().catch(onMessage)
ws = null
}
onDestroy(() => {
ws?.disconnect().catch(() => {})
})
</script>
<div class="flex flex-col gap-2">
{#if ws}
<form class="flex flex-row gap-2" on:submit|preventDefault={send}>
<input class="input grow" placeholder="Message" bind:value={message} />
<button class="btn" type="submit">Send</button>
<button class="btn" type="button" on:click={disconnect}>Disconnect</button>
</form>
{:else}
<form class="flex flex-row gap-2" on:submit|preventDefault={connect}>
<input class="input grow" placeholder="ws:// or wss:// URL" bind:value={url} />
<button class="btn" type="submit">Connect</button>
</form>
{/if}
</div>
+30 -21
View File
@@ -55,7 +55,8 @@ in the core repository; the `@tauri-apps/api` modules themselves are covered the
on the host's loopback; the iOS simulator shares that network stack, and on Android the
mobile config runs `adb reverse` so the same `127.0.0.1` URL works on the device. The
`http` specs use the echo server the example app itself spawns on port 3003, since that
is the only `http://` origin in the example's http scope.
is the only `http://` origin in the example's http scope. The fixture server also has a
WebSocket echo endpoint (`ws://127.0.0.1:3004/ws`) for the `websocket` specs.
## What is (not) covered
@@ -63,28 +64,36 @@ 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 |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
| `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, `window.Notification` override, `sendNotification`. Display is not observable. | Sending only — see the note below. |
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
| `os` | Every function, compared against what the app was built for. | Same, minus the hostname/host comparison. |
| `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. |
| `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). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
| 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, 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. |
| `stronghold` | Store records (persisted across reloads), wrong-password and unknown-client errors, vault secrets, BIP39/SLIP10 derivation and Ed25519 signing. | 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. |
| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `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.
`requestPermission` puts up a system dialog the session would then block on. The rest of the
notification API (action types, channels, the pending/active lists, listeners) only exists on
mobile, and is covered there without the permission.
The [`plugins.spec.ts`](test/specs/plugins.spec.ts) spec additionally asserts that every
plugin the platform registers injects its global API with its documented members, and that
+10 -2
View File
@@ -14,12 +14,14 @@
},
"devDependencies": {
"@crabnebula/tauri-driver": "^2.0.9",
"@crabnebula/test-runner-backend": "^0.2.9",
"@crabnebula/test-runner-backend": "^0.3.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-autostart": "workspace:*",
"@tauri-apps/plugin-barcode-scanner": "workspace:*",
"@tauri-apps/plugin-biometric": "workspace:*",
"@tauri-apps/plugin-cli": "workspace:*",
"@tauri-apps/plugin-clipboard-manager": "workspace:*",
"@tauri-apps/plugin-deep-link": "workspace:*",
"@tauri-apps/plugin-dialog": "workspace:*",
"@tauri-apps/plugin-fs": "workspace:*",
"@tauri-apps/plugin-geolocation": "workspace:*",
@@ -31,13 +33,18 @@
"@tauri-apps/plugin-notification": "workspace:*",
"@tauri-apps/plugin-opener": "workspace:*",
"@tauri-apps/plugin-os": "workspace:*",
"@tauri-apps/plugin-positioner": "workspace:*",
"@tauri-apps/plugin-process": "workspace:*",
"@tauri-apps/plugin-shell": "workspace:*",
"@tauri-apps/plugin-sql": "workspace:*",
"@tauri-apps/plugin-store": "workspace:*",
"@tauri-apps/plugin-stronghold": "workspace:*",
"@tauri-apps/plugin-updater": "workspace:*",
"@tauri-apps/plugin-upload": "workspace:*",
"@tauri-apps/plugin-websocket": "workspace:*",
"@tauri-apps/plugin-window-state": "workspace:*",
"@types/node": "^22.10.0",
"@types/ws": "^8.18.1",
"@wdio/appium-service": "^9.31.9",
"@wdio/cli": "^9.31.9",
"@wdio/globals": "^9.31.3",
@@ -49,6 +56,7 @@
"appium-xcuitest-driver": "^12.12.4",
"cross-env": "^10.1.0",
"typescript": "6.0.3",
"webdriverio": "^9.31.9"
"webdriverio": "^9.31.9",
"ws": "^8.18.0"
}
}
+17 -3
View File
@@ -4,10 +4,12 @@
import { browser } from '@wdio/globals'
import type * as TauriApi from '@tauri-apps/api'
import type * as Autostart from '@tauri-apps/plugin-autostart'
import type * as BarcodeScanner from '@tauri-apps/plugin-barcode-scanner'
import type * as Biometric from '@tauri-apps/plugin-biometric'
import type * as Cli from '@tauri-apps/plugin-cli'
import type * as ClipboardManager from '@tauri-apps/plugin-clipboard-manager'
import type * as DeepLink from '@tauri-apps/plugin-deep-link'
import type * as Dialog from '@tauri-apps/plugin-dialog'
import type * as Fs from '@tauri-apps/plugin-fs'
import type * as Geolocation from '@tauri-apps/plugin-geolocation'
@@ -19,20 +21,26 @@ import type * as Nfc from '@tauri-apps/plugin-nfc'
import type * as Notification from '@tauri-apps/plugin-notification'
import type * as Opener from '@tauri-apps/plugin-opener'
import type * as Os from '@tauri-apps/plugin-os'
import type * as Positioner from '@tauri-apps/plugin-positioner'
import type * as Process from '@tauri-apps/plugin-process'
import type * as Shell from '@tauri-apps/plugin-shell'
import type * as Sql from '@tauri-apps/plugin-sql'
import type * as Store from '@tauri-apps/plugin-store'
import type * as Stronghold from '@tauri-apps/plugin-stronghold'
import type * as Updater from '@tauri-apps/plugin-updater'
import type * as Upload from '@tauri-apps/plugin-upload'
import type * as WebSocket from '@tauri-apps/plugin-websocket'
import type * as WindowState from '@tauri-apps/plugin-window-state'
/**
* The plugin APIs the example registers on every platform, keyed by the name
* each plugin's `api-iife.js` defines on `window.__TAURI__` (the package name
* without the `@tauri-apps/plugin-` prefix, camel-cased).
* without the `@tauri-apps/plugin-` prefix, camel-cased). `sql` and `websocket`
* only have a default export, so their global is that class itself.
*/
export interface CommonPluginApi {
clipboardManager: typeof ClipboardManager
deepLink: typeof DeepLink
dialog: typeof Dialog
fs: typeof Fs
http: typeof Http
@@ -42,14 +50,19 @@ export interface CommonPluginApi {
os: typeof Os
process: typeof Process
shell: typeof Shell
sql: typeof Sql.default
store: typeof Store
stronghold: typeof Stronghold
upload: typeof Upload
websocket: typeof WebSocket.default
}
/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */
export interface DesktopPluginApi {
autostart: typeof Autostart
cli: typeof Cli
globalShortcut: typeof GlobalShortcut
positioner: typeof Positioner
updater: typeof Updater
windowState: typeof WindowState
}
@@ -231,8 +244,9 @@ const skippedModules = (process.env.E2E_SKIP ?? '')
export interface DescribePluginOptions {
/**
* The example only registers the plugin on desktop (`cli`, `global-shortcut`,
* `updater`, `window-state`), so the whole suite is skipped on mobile.
* The example only registers the plugin on desktop (`autostart`, `cli`,
* `global-shortcut`, `positioner`, `updater`, `window-state`), so the whole
* suite is skipped on mobile.
*/
desktopOnly?: boolean
/**
+39
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import http from 'node:http'
import { WebSocketServer } from 'ws'
/**
* Where the fixture server listens. The port is fixed because the updater
@@ -23,6 +24,13 @@ export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0'
export const DOWNLOAD_FIXTURE_BODY =
'hello from the plugins e2e fixture server\n'.repeat(64)
/** WebSocket endpoint of the fixture server (the `ws://` twin of {@link FIXTURE_SERVER_URL}). */
export const WEBSOCKET_FIXTURE_URL = `ws://127.0.0.1:${FIXTURE_SERVER_PORT}/ws`
/** Text message that makes the `/ws` endpoint close the connection with {@link WEBSOCKET_CLOSE_CODE}. */
export const WEBSOCKET_CLOSE_REQUEST = 'close-me'
export const WEBSOCKET_CLOSE_CODE = 4000
export const WEBSOCKET_CLOSE_REASON = 'closed by the fixture server'
export interface FixtureServer {
close(): void
}
@@ -38,6 +46,10 @@ export interface FixtureServer {
* - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`.
* - `* /echo` — a JSON description of the request (`method`, `url`, `headers`
* and the utf-8 `body`).
* - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent
* back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the
* connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server
* first sends the upgrade request's headers as a JSON text message.
*/
export function startFixtureServer(): Promise<FixtureServer> {
const server = http.createServer((req, res) => {
@@ -91,12 +103,39 @@ export function startFixtureServer(): Promise<FixtureServer> {
})
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
const { pathname } = new URL(req.url ?? '/', FIXTURE_SERVER_URL)
if (pathname !== '/ws' && pathname !== '/ws/headers') {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (pathname === '/ws/headers') {
ws.send(JSON.stringify(req.headers))
}
ws.on('message', (data, isBinary) => {
if (
!isBinary
&& Buffer.isBuffer(data)
&& data.toString('utf8') === WEBSOCKET_CLOSE_REQUEST
) {
ws.close(WEBSOCKET_CLOSE_CODE, WEBSOCKET_CLOSE_REASON)
} else {
ws.send(data, { binary: isBinary })
}
})
})
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => {
server.off('error', reject)
resolve({
close: () => {
for (const client of wss.clients) client.terminate()
wss.close()
server.closeAllConnections()
server.close()
}
@@ -0,0 +1,49 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
// Enabling autostart registers the app with the host for real (a Launch Agent
// on macOS, an XDG autostart entry on Linux, a `Run` registry value on
// Windows), so the spec puts back whatever state it found.
//
// `disable` is only called while autostart is enabled: on Windows it fails
// when there is no `Run` value to delete.
describePlugin('autostart', { desktopOnly: true }, () => {
let initiallyEnabled = false
before(async () => {
initiallyEnabled = await tauri((api) => api.autostart.isEnabled())
})
after(async () => {
await tauri(async (api, enabled) => {
if ((await api.autostart.isEnabled()) === enabled) return
await (enabled ? api.autostart.enable() : api.autostart.disable())
}, initiallyEnabled)
})
it('enable and disable toggle isEnabled', async () => {
const states = await tauri(async (api) => {
await api.autostart.enable()
const enabled = await api.autostart.isEnabled()
await api.autostart.disable()
return { enabled, disabled: await api.autostart.isEnabled() }
})
expect(states).toEqual({ enabled: true, disabled: false })
})
it('enabling twice keeps it enabled', async () => {
const states = await tauri(async (api) => {
await api.autostart.enable()
await api.autostart.enable()
const enabled = await api.autostart.isEnabled()
await api.autostart.disable()
return { enabled, disabled: await api.autostart.isEnabled() }
})
expect(states).toEqual({ enabled: true, disabled: false })
})
})
@@ -7,7 +7,8 @@ import {
tauri,
tauriError,
describePlugin,
itDesktop
itDesktop,
isMobile
} from '../helpers/index.js'
// The mobile implementation only carries plain text: `write_html`, `write_image`
@@ -133,7 +134,11 @@ describePlugin('clipboard-manager', () => {
await api.clipboardManager.writeText('not an image')
await api.clipboardManager.readImage()
})
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(
isMobile
? /Unsupported on this platform/
: /not available in the requested format/
)
})
it('clear empties the clipboard', async () => {
@@ -0,0 +1,99 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
itOn,
platform
} from '../helpers/index.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
// emitting the event the plugin itself emits when the app is opened with one.
//
// 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.
/** A scheme no real app handles, so registering it cannot clobber anything. */
const scheme = 'tauri-plugins-e2e'
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()
})
it('onOpenUrl delivers the opened URLs until unlistened', async () => {
const received = await tauri(
async (api, urls) => {
const received: string[][] = []
const unlisten = await api.deepLink.onOpenUrl((urls) =>
received.push(urls)
)
await api.event.emit('deep-link://new-url', urls)
await new Promise((resolve) => setTimeout(resolve, 500))
unlisten()
await api.event.emit('deep-link://new-url', ['ignored://'])
await new Promise((resolve) => setTimeout(resolve, 500))
return received
},
[`${scheme}://open/path?query=1`]
)
expect(received).toEqual([[`${scheme}://open/path?query=1`]])
})
itOn(
['linux', 'win32'],
'register makes the app the scheme handler',
async () => {
const result = await tauri(async (api, scheme) => {
await api.deepLink.register(scheme)
const registered = await api.deepLink.isRegistered(scheme)
await api.deepLink.unregister(scheme)
return {
registered,
afterUnregister: await api.deepLink.isRegistered(scheme)
}
}, scheme)
expect(result.registered).toBe(true)
// On Linux `xdg-mime` falls back to the desktop database, which still lists
// the handler after its `mimeapps.list` default is removed.
if (platform === 'win32') {
expect(result.afterUnregister).toBe(false)
}
}
)
itOn('win32', 'isRegistered is false for an unknown scheme', async () => {
expect(
await tauri(
(api, scheme) => api.deepLink.isRegistered(scheme),
`${scheme}-unknown`
)
).toBe(false)
})
itOn(
['darwin', 'android', 'ios'],
'runtime registration is unsupported',
async () => {
for (const command of [
'register',
'unregister',
'isRegistered'
] as const) {
const error = await tauriError(
// eslint-disable-next-line security/detect-object-injection
(api, command, scheme) => api.deepLink[command](scheme),
command,
scheme
)
expect(error).toMatch(/unsupported platform/i)
}
}
)
})
+4 -3
View File
@@ -276,7 +276,7 @@ describePlugin('fs', () => {
await file.close()
await file.stat()
}, `${dir}/closed.txt`)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/resource id \d+ is invalid/)
})
it('rejects paths outside the configured scope', async () => {
@@ -339,8 +339,9 @@ describePlugin('fs', () => {
unwatch()
return events
}, `${dir}/watched`)
expect(result.length).toBeGreaterThan(0)
expect(result.every((e) => typeof e.kind === 'string')).toBe(true)
expect(
result.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
).toBe(true)
}
)
@@ -3,7 +3,12 @@
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
import {
tauri,
tauriError,
describePlugin,
platform
} from '../helpers/index.js'
// Triggering a shortcut needs OS-level synthetic input the WebDriver session
// cannot produce, so the specs cover the registry (register / isRegistered /
@@ -74,9 +79,19 @@ describePlugin('global-shortcut', { desktopOnly: true }, () => {
it('registering the same shortcut twice rejects', async () => {
const message = await tauriError(async (api) => {
await api.globalShortcut.register('Alt+Shift+F11', () => {})
await api.globalShortcut.register('Alt+Shift+F11', () => {})
try {
await api.globalShortcut.register('Alt+Shift+F11', () => {})
} finally {
await api.globalShortcut.unregister('Alt+Shift+F11')
}
})
expect(message).toMatch(/already registered/i)
// macOS does not check for duplicates itself: the OS rejects the second
// `RegisterEventHotKey` call and the error is the generic one
expect(message).toMatch(
platform === 'darwin'
? /RegisterEventHotKey failed for F11/
: /already registered/i
)
})
it('rejects shortcuts that cannot be parsed', async () => {
+1 -1
View File
@@ -144,6 +144,6 @@ describePlugin('http', () => {
api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }),
echoServer
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/error sending request/)
})
})
+105 -22
View File
@@ -3,15 +3,25 @@
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin, itDesktop } from '../helpers/index.js'
import {
tauri,
tauriError,
describePlugin,
itDesktop,
itOn
} from '../helpers/index.js'
// Whether a notification actually shows up depends on the desktop session
// (a notification daemon on Linux, the app's registration on Windows and
// macOS), which the suite cannot observe. The specs cover the permission
// model and that sending does not error out synchronously.
// macOS), which the suite cannot observe, and sending is fire-and-forget, so
// only the permission model is covered on desktop.
//
// The permission specs are desktop-only: mobile starts out ungranted and
// `requestPermission` puts up a system dialog the session would then block on.
//
// Scheduling, action types, channels, the pending/active lists and the
// listeners are only implemented on mobile. None of them need the permission,
// so they are covered there on an app that has not been granted it.
describePlugin('notification', () => {
itDesktop('permission is granted on desktop', async () => {
@@ -30,29 +40,102 @@ describePlugin('notification', () => {
expect(result).toEqual({ permission: 'granted', requested: 'granted' })
})
it('sendNotification accepts a title string and an options object', async () => {
const result = await tauri((api) => {
api.notification.sendNotification('notification from e2e')
api.notification.sendNotification({
title: 'notification from e2e',
body: 'with a body',
sound: 'default'
itOn(
['android', 'ios'],
'action types register, and cancel/remove leave nothing pending or active',
async () => {
const result = await tauri(async (api) => {
await api.notification.registerActionTypes([
{
id: 'e2e-actions',
actions: [
{ id: 'reply', title: 'Reply', input: true },
{ id: 'dismiss', title: 'Dismiss', destructive: true }
]
}
])
await api.notification.cancel([424242])
await api.notification.cancelAll()
await api.notification.removeActive([{ id: 424242 }])
await api.notification.removeAllActive()
return {
pending: await api.notification.pending(),
active: await api.notification.active()
}
})
return true
expect(result).toEqual({ pending: [], active: [] })
}
)
itOn('android', 'channels can be created, listed and removed', async () => {
const result = await tauri(async (api) => {
const { Importance, Visibility } = api.notification
await api.notification.createChannel({
id: 'e2e-channel',
name: 'e2e channel',
description: 'created by the e2e suite',
importance: Importance.High,
visibility: Visibility.Public,
vibration: true
})
const created = (await api.notification.channels()).find(
(channel) => channel.id === 'e2e-channel'
)
await api.notification.removeChannel('e2e-channel')
const removed = (await api.notification.channels()).some(
(channel) => channel.id === 'e2e-channel'
)
return {
created: created && {
name: created.name,
description: created.description,
importance: created.importance,
vibration: created.vibration
},
removed
}
})
expect(result).toEqual({
created: {
name: 'e2e channel',
description: 'created by the e2e suite',
importance: 4, // Importance.High
vibration: true
},
removed: false
})
expect(result).toBe(true)
})
it('new Notification() goes through the plugin', async () => {
const result = await tauri(() => {
const notification = new window.Notification(
'window.Notification from e2e',
{
body: 'created through the DOM API'
}
itOn('ios', 'channels are not implemented', async () => {
for (const call of ['create', 'remove', 'list'] as const) {
const error = await tauriError(
(api, call) =>
call === 'create'
? api.notification.createChannel({ id: 'e2e', name: 'e2e' })
: call === 'remove'
? api.notification.removeChannel('e2e')
: api.notification.channels(),
call
)
return typeof notification === 'object'
})
expect(result).toBe(true)
expect(error).toMatch(/not implemented/)
}
})
itOn(
['android', 'ios'],
'onNotificationReceived and onAction listeners register and unregister',
async () => {
const result = await tauri(async (api) => {
const received = await api.notification.onNotificationReceived(() => {})
const action = await api.notification.onAction(() => {})
await received.unregister()
await action.unregister()
return { received: received.event, action: action.event }
})
expect(result).toEqual({
received: 'notification',
action: 'actionPerformed'
})
}
)
})
+1 -1
View File
@@ -41,6 +41,6 @@ describePlugin('opener', () => {
await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e')
)
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/os error 2/)
})
})
-11
View File
@@ -86,11 +86,6 @@ describePlugin('os', () => {
expect(info.exeExtension).toBe(platform === 'win32' ? 'exe' : '')
})
it('version reports a non-empty OS version', async () => {
const version = await tauri((api) => api.os.version())
expect(version.length).toBeGreaterThan(0)
})
it('locale is null or a language tag', async () => {
const locale = await tauri((api) => api.os.locale())
// The plugin forwards the environment's POSIX locale as-is, so a host with
@@ -106,12 +101,6 @@ describePlugin('os', () => {
}
})
it('hostname reports a name', async () => {
const hostname = await tauri((api) => api.os.hostname())
expect(hostname).not.toBeNull()
expect(hostname!.length).toBeGreaterThan(0)
})
itDesktop('hostname matches the host', async () => {
const hostname = await tauri((api) => api.os.hostname())
// Windows can report the name in a different case than Node does.
+26 -7
View File
@@ -31,6 +31,13 @@ const commonSurface: Surface<CommonPluginApi> = {
'readImage',
'writeImage'
],
deepLink: [
'getCurrent',
'register',
'unregister',
'isRegistered',
'onOpenUrl'
],
dialog: ['open', 'save', 'message', 'ask', 'confirm'],
fs: [
'BaseDirectory',
@@ -98,14 +105,26 @@ const commonSurface: Surface<CommonPluginApi> = {
],
process: ['exit', 'relaunch'],
shell: ['Command', 'Child', 'EventEmitter', 'open'],
// the `Database` class itself
sql: ['load', 'get'],
store: ['load', 'getStore', 'LazyStore', 'Store'],
upload: ['download', 'upload', 'HttpMethod']
stronghold: ['Location', 'Client', 'Store', 'Vault', 'Stronghold'],
upload: ['download', 'upload', 'HttpMethod'],
// the `WebSocket` class itself
websocket: ['connect']
}
/** Plugins the example only registers on desktop. */
const desktopSurface: Surface<DesktopPluginApi> = {
autostart: ['enable', 'disable', 'isEnabled'],
cli: ['getMatches'],
globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'],
positioner: [
'Position',
'moveWindow',
'moveWindowConstrained',
'handleIconState'
],
updater: ['check', 'Update'],
windowState: [
'StateFlags',
@@ -168,12 +187,12 @@ describe('plugin globals', () => {
it('every plugin this platform registers exposes its API on window.__TAURI__', async () => {
const missing = await tauri(
(api, plugins) =>
plugins.filter(
(plugin) =>
// eslint-disable-next-line security/detect-object-injection
typeof (api as unknown as Record<string, unknown>)[plugin]
!== 'object'
),
plugins.filter((plugin) => {
// eslint-disable-next-line security/detect-object-injection
const global = (api as unknown as Record<string, unknown>)[plugin]
// a module namespace, or the class a default-export-only module (sql, websocket) defines
return typeof global !== 'object' && typeof global !== 'function'
}),
Object.keys(surface)
)
expect(missing).toEqual([])
@@ -0,0 +1,137 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin, itWm } from '../helpers/index.js'
// Positions are computed from the window's current monitor and outer size, so
// the specs compute the expected physical position the same way and compare
// once the window manager has applied the move. The tray positions need the
// tray icon's rect, which normally comes from a click on the icon; the spec
// hands one in through `handleIconState`, as a JS tray event handler would.
// The moved window is not put back: each spec file gets a fresh app.
interface Geometry {
monitor: { x: number; y: number; width: number; height: number }
window: { width: number; height: number }
}
/** Moves the main window and reports where it ended up along with the geometry the position is derived from. */
function moveAndMeasure(position: string, constrained = false) {
return tauri(
async (api, position, constrained) => {
const win = api.window.getCurrentWindow()
const { Position } = api.positioner
const value = Position[position as keyof typeof Position]
await (constrained
? api.positioner.moveWindowConstrained(value)
: api.positioner.moveWindow(value))
await new Promise((resolve) => setTimeout(resolve, 500))
const monitor = await api.window.currentMonitor()
const size = await win.outerSize()
const outer = await win.outerPosition()
return {
monitor: {
x: monitor!.position.x,
y: monitor!.position.y,
width: monitor!.size.width,
height: monitor!.size.height
},
window: { width: size.width, height: size.height },
position: { x: outer.x, y: outer.y }
}
},
position,
constrained
)
}
function center({ monitor, window }: Geometry) {
return {
x: monitor.x + Math.trunc(monitor.width / 2) - Math.trunc(window.width / 2),
y:
monitor.y + Math.trunc(monitor.height / 2) - Math.trunc(window.height / 2)
}
}
describePlugin('positioner', { desktopOnly: true }, () => {
// must run before any tray rect is handed in
it('tray positions are rejected until the tray icon reported its rect', async () => {
const error = await tauriError((api) =>
api.positioner.moveWindow(api.positioner.Position.TrayCenter)
)
expect(error).toMatch(/Tray position not set/)
})
it('the Position enum matches the plugin', async () => {
const names = await tauri((api) =>
Object.keys(api.positioner.Position).filter((key) => isNaN(Number(key)))
)
expect(names).toEqual([
'TopLeft',
'TopRight',
'BottomLeft',
'BottomRight',
'TopCenter',
'BottomCenter',
'LeftCenter',
'RightCenter',
'Center',
'TrayLeft',
'TrayBottomLeft',
'TrayRight',
'TrayBottomRight',
'TrayCenter',
'TrayBottomCenter'
])
})
itWm('moveWindow centers the window on its monitor', async () => {
const result = await moveAndMeasure('Center')
expect(result.position).toEqual(center(result))
})
itWm(
'moveWindowConstrained behaves like moveWindow for screen positions',
async () => {
const result = await moveAndMeasure('Center', true)
expect(result.position).toEqual(center(result))
}
)
itWm('tray positions are relative to the reported tray rect', async () => {
// a tray "icon" in the middle of the window's monitor, far from any edge,
// so neither the OS-specific flips nor the constraint kick in
const tray = await tauri(async (api) => {
const monitor = await api.window.currentMonitor()
const rect = {
x: monitor!.position.x + Math.trunc(monitor!.size.width / 2),
y: monitor!.position.y + Math.trunc(monitor!.size.height / 2),
width: 20,
height: 20
}
await api.positioner.handleIconState({
type: 'Click',
id: 'e2e',
position: new api.dpi.PhysicalPosition(rect.x, rect.y),
rect: {
position: new api.dpi.PhysicalPosition(rect.x, rect.y),
size: new api.dpi.PhysicalSize(rect.width, rect.height)
},
button: 'Left',
buttonState: 'Down'
})
return rect
})
const bottomLeft = await moveAndMeasure('TrayBottomLeft')
expect(bottomLeft.position).toEqual({ x: tray.x, y: tray.y })
const bottomCenter = await moveAndMeasure('TrayBottomCenter', true)
expect(bottomCenter.position).toEqual({
x: tray.x + tray.width / 2 - Math.trunc(bottomCenter.window.width / 2),
y: tray.y
})
})
})
+1 -1
View File
@@ -233,6 +233,6 @@ describePlugin('shell', () => {
const message = await tauriError((api) =>
api.shell.open('ftp://example.com')
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/failed regex validation/)
})
})
+168
View File
@@ -0,0 +1,168 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
// SQLite databases live in the app config directory. The spec works on its
// own database, recreating its table before each test, and only reads the
// example's `sqlite:api.db` to check the migration the app registers for it.
const db = 'sqlite:e2e-sql.db'
interface Row {
id: number
name: string
score: number | null
ratio: number | null
}
describePlugin('sql', () => {
beforeEach(async () => {
await tauri(async (api, db) => {
const database = await api.sql.load(db)
await database.execute('DROP TABLE IF EXISTS e2e')
await database.execute(
'CREATE TABLE e2e (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score INTEGER, ratio REAL)'
)
}, db)
})
it('load resolves with a database for the given path', async () => {
const path = await tauri(
async (api, db) => (await api.sql.load(db)).path,
db
)
expect(path).toBe(db)
})
it('migrations registered by the app run when their database is loaded', async () => {
const result = await tauri(async (api) => {
const database = await api.sql.load('sqlite:api.db')
return {
tables: await database.select<{ name: string }[]>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'todos'"
),
migrations: await database.select<
{ version: number; description: string; success: number }[]
>('SELECT version, description, success FROM _sqlx_migrations')
}
})
expect(result.tables).toEqual([{ name: 'todos' }])
// `success` is declared BOOLEAN but stored, and read back, as an integer
expect(result.migrations).toEqual([
{ version: 1, description: 'create_todos_table', success: 1 }
])
})
it('execute reports affected rows and the last insert id', async () => {
const results = await tauri(async (api, db) => {
const database = api.sql.get(db)
const first = await database.execute(
'INSERT INTO e2e (name, score) VALUES ($1, $2)',
['first', 1]
)
const second = await database.execute(
'INSERT INTO e2e (name, score) VALUES ($1, $2)',
['second', 2]
)
const update = await database.execute('UPDATE e2e SET score = score + 10')
return { first, second, update }
}, db)
expect(results.first).toEqual({ rowsAffected: 1, lastInsertId: 1 })
expect(results.second).toEqual({ rowsAffected: 1, lastInsertId: 2 })
expect(results.update.rowsAffected).toBe(2)
})
it('select returns rows as objects, with bound values and column types', async () => {
const rows = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute(
'INSERT INTO e2e (name, score, ratio) VALUES ($1, $2, $3), ($4, $5, $6)',
['alpha', 42, 0.5, 'beta', null, null]
)
return {
all: await database.select<Row[]>('SELECT * FROM e2e ORDER BY id'),
filtered: await database.select<Row[]>(
'SELECT name FROM e2e WHERE score = $1',
[42]
),
none: await database.select<Row[]>(
'SELECT * FROM e2e WHERE name = $1',
['missing']
)
}
}, db)
expect(rows.all).toEqual([
{ id: 1, name: 'alpha', score: 42, ratio: 0.5 },
{ id: 2, name: 'beta', score: null, ratio: null }
])
expect(rows.filtered).toEqual([{ name: 'alpha' }])
expect(rows.none).toEqual([])
})
it('column order follows the query', async () => {
const columns = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute("INSERT INTO e2e (name, score) VALUES ('x', 1)")
const [row] = await database.select<Record<string, unknown>[]>(
'SELECT score, name, id FROM e2e'
)
return Object.keys(row)
}, db)
expect(columns).toEqual(['score', 'name', 'id'])
})
it('invalid statements reject', async () => {
const error = await tauriError(
(api, db) => api.sql.get(db).execute('INSERT INTO missing VALUES (1)'),
db
)
expect(error).toMatch(/no such table: missing/)
const constraint = await tauriError(
(api, db) =>
api.sql.get(db).execute('INSERT INTO e2e (name) VALUES ($1)', [null]),
db
)
expect(constraint).toMatch(/NOT NULL constraint failed/)
})
it('a database that was never loaded is rejected', async () => {
const error = await tauriError((api) =>
api.sql.get('sqlite:never-loaded.db').select('SELECT 1')
)
expect(error).toMatch(/database sqlite:never-loaded\.db not loaded/)
})
it('close shuts the pool down until the database is loaded again', async () => {
const result = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute("INSERT INTO e2e (name) VALUES ('persisted')")
const closed = await database.close(db)
let error = ''
try {
await database.select('SELECT * FROM e2e')
} catch (e) {
error = String(e)
}
const reloaded = await api.sql.load(db)
return {
closed,
error,
rows: await reloaded.select<{ name: string }[]>('SELECT name FROM e2e')
}
}, db)
expect(result.closed).toBe(true)
expect(result.error).toMatch(/closed/i)
// the data was written to disk, not just to the closed pool
expect(result.rows).toEqual([{ name: 'persisted' }])
})
it('closing a database that was never loaded is rejected', async () => {
const error = await tauriError((api) =>
api.sql.get('sqlite:never-loaded.db').close('sqlite:never-loaded.db')
)
expect(error).toMatch(/not loaded/)
})
})
@@ -0,0 +1,248 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
scratchDir
} from '../helpers/index.js'
// The example derives the snapshot key from the password with argon2. Each
// test works on its own snapshot file under the spec's scratch directory,
// which the fs plugin cleans up; the snapshot path is resolved inside the page.
const dir = scratchDir('stronghold')
const password = 'e2e-password'
const clientName = 'e2e-client'
/** An arbitrary, valid BIP39 mnemonic, so derived keys are deterministic. */
const mnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
describePlugin('stronghold', () => {
before(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
await api.fs.mkdir(dir, { baseDir, recursive: true })
}, dir)
})
after(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
}, dir)
})
it('store records round-trip and persist in the snapshot', async () => {
const result = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'store.stronghold'
)
const value = Array.from(new TextEncoder().encode('top secret'))
const stronghold = await api.stronghold.Stronghold.load(path, password)
const client = await stronghold.createClient(clientName)
await client.getStore().insert('key', value)
const sameSession = await client.getStore().get('key')
await stronghold.save()
await stronghold.unload()
const reopened = await api.stronghold.Stronghold.load(path, password)
const store = (await reopened.loadClient(clientName)).getStore()
const persisted = await store.get('key')
const missing = await store.get('missing')
await reopened.unload()
return {
sameSession: sameSession && Array.from(sameSession),
persisted: persisted && new TextDecoder().decode(persisted),
missing,
fileExists: await api.fs.exists(path)
}
},
dir,
password,
clientName
)
expect(result.sameSession).toEqual(
Array.from(new TextEncoder().encode('top secret'))
)
expect(result.persisted).toBe('top secret')
expect(result.missing).toBeNull()
expect(result.fileExists).toBe(true)
})
it('store.remove returns the removed value', async () => {
const result = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'remove.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
const store = (await stronghold.createClient(clientName)).getStore()
await store.insert('key', [1, 2, 3])
const removed = await store.remove('key')
const after = await store.get('key')
await stronghold.unload()
return { removed: removed && Array.from(removed), after }
},
dir,
password,
clientName
)
expect(result).toEqual({ removed: [1, 2, 3], after: null })
})
it('a snapshot cannot be opened with the wrong password', async () => {
const path = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'password.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
await stronghold.createClient(clientName)
await stronghold.unload()
return path
},
dir,
password,
clientName
)
const error = await tauriError(
(api, path) => api.stronghold.Stronghold.load(path, 'wrong-password'),
path
)
expect(error).toMatch(/failed to decode\/decrypt/)
})
it('loading a client that was never created is rejected', async () => {
const error = await tauriError(
async (api, dir, password) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'no-client.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
try {
await stronghold.loadClient('never-created')
} finally {
await stronghold.unload()
}
},
dir,
password
)
expect(error).toMatch(/error loading client data/)
})
it('vault procedures derive keys and sign without exposing secrets', async () => {
const result = await tauri(
async (api, dir, password, clientName, mnemonic) => {
const { Location } = api.stronghold
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'vault.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
const vault = (await stronghold.createClient(clientName)).getVault(
'vault'
)
// the same mnemonic recovered twice derives the same key
const seedA = Location.generic('vault', 'seed-a')
const seedB = Location.generic('vault', 'seed-b')
await vault.recoverBIP39(mnemonic, seedA)
await vault.recoverBIP39(mnemonic, seedB)
// Ed25519 SLIP-10 only derives hardened indices (the high bit set).
// (No named helper: the transpiler would wrap it in a `__name` call
// that does not exist in the page.)
const chain = [44, 4218, 0, 0, 0].map((i) => (i | 0x80000000) >>> 0)
const otherChain = [44, 4218, 0, 0, 1].map(
(i) => (i | 0x80000000) >>> 0
)
const keyA = Location.generic('vault', 'key-a')
const keyB = Location.generic('vault', 'key-b')
const keyOther = Location.generic('vault', 'key-other')
await vault.deriveSLIP10(chain, 'Seed', seedA, keyA)
await vault.deriveSLIP10(chain, 'Seed', seedB, keyB)
await vault.deriveSLIP10(otherChain, 'Seed', seedA, keyOther)
const publicA = Array.from(await vault.getEd25519PublicKey(keyA))
const publicB = Array.from(await vault.getEd25519PublicKey(keyB))
const publicOther = Array.from(
await vault.getEd25519PublicKey(keyOther)
)
const signature = Array.from(await vault.signEd25519(keyA, 'message'))
const signatureAgain = Array.from(
await vault.signEd25519(keyA, 'message')
)
// a random seed and a raw secret can be stored and removed
const random = Location.generic('vault', 'random')
await vault.generateSLIP10Seed(random)
await vault.insert('raw', [9, 9, 9])
await vault.remove(Location.generic('vault', 'raw'))
// a generated mnemonic recovers the seed generateBIP39 stored
const generatedSeed = Location.generic('vault', 'generated')
const mnemonicBytes = await vault.generateBIP39(generatedSeed)
const recoveredSeed = Location.generic('vault', 'recovered')
await vault.recoverBIP39(
new TextDecoder().decode(mnemonicBytes),
recoveredSeed
)
const generatedKey = Location.generic('vault', 'generated-key')
const recoveredKey = Location.generic('vault', 'recovered-key')
await vault.deriveSLIP10(chain, 'Seed', generatedSeed, generatedKey)
await vault.deriveSLIP10(chain, 'Seed', recoveredSeed, recoveredKey)
const generatedPublic = Array.from(
await vault.getEd25519PublicKey(generatedKey)
)
const recoveredPublic = Array.from(
await vault.getEd25519PublicKey(recoveredKey)
)
await stronghold.unload()
return {
publicA,
publicB,
publicOther,
signature,
signatureAgain,
generatedPublic,
recoveredPublic
}
},
dir,
password,
clientName,
mnemonic
)
expect(result.publicA).toHaveLength(32)
expect(result.publicB).toEqual(result.publicA)
expect(result.publicOther).not.toEqual(result.publicA)
// Ed25519 signatures are 64 bytes and deterministic
expect(result.signature).toHaveLength(64)
expect(result.signatureAgain).toEqual(result.signature)
expect(result.recoveredPublic).toEqual(result.generatedPublic)
expect(result.generatedPublic).not.toEqual(result.publicA)
})
})
+1 -1
View File
@@ -156,6 +156,6 @@ describePlugin('upload', () => {
`${FIXTURE_SERVER_URL}/echo`,
`${dir}/does-not-exist.txt`
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/os error 2/)
})
})
@@ -0,0 +1,185 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
import {
WEBSOCKET_FIXTURE_URL,
WEBSOCKET_CLOSE_REQUEST,
WEBSOCKET_CLOSE_CODE,
WEBSOCKET_CLOSE_REASON
} from '../helpers/server.js'
// Every test talks to the fixture server's `/ws` echo endpoint (through
// `adb reverse` on Android). Messages reach the page through the plugin's
// channel, so each page function collects them until it has what it expects,
// with its own timeout so a missing message fails with a readable error.
interface Message {
type: string
data: unknown
}
describePlugin('websocket', () => {
it('echoes text and binary messages', async () => {
const messages = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
const received: Message[] = []
await new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error('echo not received')), 5000)
ws.addListener((message) => {
received.push(message)
if (received.length === 3) resolve()
})
ws.send('hello')
.then(() => ws.send([1, 2, 3]))
.then(() => ws.send({ type: 'Text', data: 'explicit' }))
.catch(reject)
})
await ws.disconnect()
return received
}, WEBSOCKET_FIXTURE_URL)
expect(messages).toEqual([
{ type: 'Text', data: 'hello' },
{ type: 'Binary', data: [1, 2, 3] },
{ type: 'Text', data: 'explicit' }
])
})
it('a ping is answered with a pong carrying its payload', async () => {
const message = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
const pong = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('pong not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Pong') resolve(message)
})
ws.send({ type: 'Ping', data: [7, 8, 9] }).catch(reject)
})
await ws.disconnect()
return pong
}, WEBSOCKET_FIXTURE_URL)
expect(message).toEqual({ type: 'Pong', data: [7, 8, 9] })
})
it('sends the configured headers with the upgrade request', async () => {
const headers = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url, {
headers: { 'x-e2e-header': 'from the plugin' }
})
const message = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('headers not received')), 5000)
ws.addListener(resolve)
})
await ws.disconnect()
return JSON.parse(message.data as string) as Record<string, string>
}, `${WEBSOCKET_FIXTURE_URL}/headers`)
expect(headers['x-e2e-header']).toBe('from the plugin')
})
it('a listener stops receiving once removed', async () => {
const counts = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
let removed = 0
let kept = 0
const remove = ws.addListener(() => removed++)
await new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error('echoes not received')), 5000)
ws.addListener(() => {
kept++
if (kept === 1) {
remove()
ws.send('second').catch(reject)
} else {
resolve()
}
})
ws.send('first').catch(reject)
})
await ws.disconnect()
return { removed, kept }
}, WEBSOCKET_FIXTURE_URL)
expect(counts).toEqual({ removed: 1, kept: 2 })
})
it('a close from the server is delivered and ends the connection', async () => {
const result = await tauri(
async (api, url, closeRequest) => {
const ws = await api.websocket.connect(url)
const close = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('close not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Close') resolve(message)
})
ws.send(closeRequest).catch(reject)
})
let sendError = ''
try {
await ws.send('after close')
} catch (error) {
sendError = String(error)
}
return { close, sendError }
},
WEBSOCKET_FIXTURE_URL,
WEBSOCKET_CLOSE_REQUEST
)
expect(result.close).toEqual({
type: 'Close',
data: { code: WEBSOCKET_CLOSE_CODE, reason: WEBSOCKET_CLOSE_REASON }
})
expect(result.sendError).toMatch(/connection not found/)
})
it('disconnect sends a normal close frame', async () => {
const close = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
// the server acknowledges the close with the same frame
return await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('close not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Close') resolve(message)
})
ws.disconnect().catch(reject)
})
}, WEBSOCKET_FIXTURE_URL)
expect(close).toEqual({
type: 'Close',
data: { code: 1000, reason: 'Disconnected by client' }
})
})
it('connecting to a closed port is rejected', async () => {
// port 1 (tcpmux) is never listening on the loopback interface
const error = await tauriError((api) =>
api.websocket.connect('ws://127.0.0.1:1/')
)
expect(error).toMatch(/refused|connect/i)
})
it('invalid URLs and header names are rejected', async () => {
const url = await tauriError((api) => api.websocket.connect('not a url'))
expect(url).toMatch(/invalid uri/i)
const header = await tauriError(
(api, url) =>
api.websocket.connect(url, { headers: [['bad header', 'value']] }),
WEBSOCKET_FIXTURE_URL
)
// Chromium (Android) already rejects the name in the `Headers` constructor;
// WebKit passes it through and the plugin rejects it
expect(header).toMatch(/invalid (header )?name/i)
})
it('sending an unsupported message type throws', async () => {
const error = await tauriError(async (api, url) => {
const ws = await api.websocket.connect(url)
try {
await ws.send(42 as unknown as string)
} finally {
await ws.disconnect()
}
}, WEBSOCKET_FIXTURE_URL)
expect(error).toMatch(/invalid `message` type/)
})
})
+42 -3
View File
@@ -164,7 +164,8 @@ export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
'--apk',
'--target',
androidTarget()
]
],
ios ? {} : androidBuildEnv()
)
}
@@ -282,11 +283,12 @@ for (const signal of [
}
/** Runs `pnpm tauri <args>` in examples/api, failing loudly. */
function tauriCli(args: string[]): void {
function tauriCli(args: string[], env: NodeJS.ProcessEnv = {}): void {
const result = spawnSync('pnpm', ['tauri', ...args], {
cwd: appDir,
stdio: 'inherit',
shell: true
shell: true,
env: { ...process.env, ...env }
})
if (result.status !== 0) {
throw new Error(
@@ -297,6 +299,39 @@ function tauriCli(args: string[]): void {
// --- Android -----------------------------------------------------------------
/**
* Environment for the Android build.
*
* - No debug info: the suite needs a debug build (webview debugging follows
* `debug_assertions`), but with the stronghold, sql and websocket plugins the
* debug info alone grows the APK past what a default emulator can install
* ("not enough space").
* - On a macOS host, `AR`/`RANLIB` point at the NDK's LLVM tools. Autotools-built
* C dependencies (libsodium, through the stronghold plugin) otherwise fall back
* to Apple's `ar`/`ranlib`, which silently produce an empty archive from the
* Android (ELF) objects, and the app then fails to load its library with an
* unresolved symbol. Linux hosts' GNU `ar` is fine.
*/
function androidBuildEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { CARGO_PROFILE_DEV_DEBUG: '0' }
const ndk = process.env.NDK_HOME ?? process.env.ANDROID_NDK_HOME
if (process.platform !== 'darwin' || !ndk) return env
// the NDK only ships an x86_64 (Rosetta-compatible) macOS toolchain
const bin = path.join(
ndk,
'toolchains',
'llvm',
'prebuilt',
'darwin-x86_64',
'bin'
)
return {
...env,
AR: path.join(bin, 'llvm-ar'),
RANLIB: path.join(bin, 'llvm-ranlib')
}
}
/** `adb` from the Android SDK, else whatever is on `PATH`. */
function adb(args: string[]): SpawnSyncReturns<string> {
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
@@ -329,6 +364,10 @@ function androidCapabilities(app: string): WebdriverIO.Capabilities {
'appium:app': app,
'appium:appPackage': appId,
'appium:appActivity': '.MainActivity',
// Every build has the same version code, and Appium otherwise skips
// installing an APK whose version is already on the device, so a rebuilt
// app would never replace the installed one.
'appium:enforceAppInstall': true,
// Which device/emulator to use; Appium picks the first connected one
// otherwise. E2E_ANDROID_AVD instead boots that AVD.
...(process.env.E2E_ANDROID_DEVICE
@@ -39,7 +39,8 @@ class BatchArgs {
@InvokeArg
class CancelArgs {
lateinit var notifications: List<Int>
// `cancelAll()` sends no list: cancel every pending notification
var notifications: List<Int>? = null
}
@InvokeArg
@@ -152,7 +153,9 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
@Command
fun cancel(invoke: Invoke) {
val args = invoke.parseArgs(CancelArgs::class.java)
manager.cancel(args.notifications)
manager.cancel(
args.notifications ?: notificationStorage.getSavedNotificationIds().mapNotNull { it.toIntOrNull() }
)
invoke.resolve()
}
@@ -192,32 +195,33 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
@SuppressLint("ObsoleteSdkInt")
@Command
fun getActive(invoke: Invoke) {
val notifications = JSArray()
// plain lists and maps: until tauri serializes org.json values natively,
// `resolveObject` turns a `JSArray` into `{ "values": [...] }`
val notifications = mutableListOf<Map<String, Any?>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNotifications = notificationManager.activeNotifications
for (activeNotification in activeNotifications) {
val jsNotification = JSObject()
jsNotification.put("id", activeNotification.id)
jsNotification.put("tag", activeNotification.tag)
val jsNotification = mutableMapOf<String, Any?>(
"id" to activeNotification.id,
"tag" to activeNotification.tag
)
val notification = activeNotification.notification
if (notification != null) {
jsNotification.put("title", notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE))
jsNotification.put("body", notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT))
jsNotification.put("group", notification.group)
jsNotification.put(
"groupSummary",
jsNotification["title"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)?.toString()
jsNotification["body"] = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString()
jsNotification["group"] = notification.group
jsNotification["groupSummary"] =
0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY
)
val extras = JSObject()
val extras = mutableMapOf<String, String?>()
for (key in notification.extras.keySet()) {
extras.put(key!!, notification.extras.getString(key))
extras[key] = notification.extras.getString(key)
}
jsNotification.put("data", extras)
jsNotification["data"] = extras
}
notifications.put(jsNotification)
notifications.add(jsNotification)
}
}
invoke.resolveObject(notifications)
}
+1 -1
View File
@@ -1 +1 @@
if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i,n){"use strict";var e,t,o;i.ScheduleEvery=void 0,(e=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",e.Month="month",e.TwoWeeks="twoWeeks",e.Week="week",e.Day="day",e.Hour="hour",e.Minute="minute",e.Second="second";return i.Importance=void 0,(t=i.Importance||(i.Importance={}))[t.None=0]="None",t[t.Min=1]="Min",t[t.Low=2]="Low",t[t.Default=3]="Default",t[t.High=4]="High",i.Visibility=void 0,(o=i.Visibility||(i.Visibility={}))[o.Secret=-1]="Secret",o[o.Private=0]="Private",o[o.Public=1]="Public",i.Schedule=class{static at(i,n=!1,e=!1){return{at:{date:i,repeating:n,allowWhileIdle:e},interval:void 0,every:void 0}}static interval(i,n=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:n},every:void 0}}static every(i,n,e=!1){return{at:void 0,interval:void 0,every:{interval:i,count:n,allowWhileIdle:e}}}},i.active=async function(){return await n.invoke("plugin:notification|get_active")},i.cancel=async function(i){await n.invoke("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await n.invoke("plugin:notification|cancel")},i.channels=async function(){return await n.invoke("plugin:notification|listChannels")},i.createChannel=async function(i){await n.invoke("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await n.invoke("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await n.addPluginListener("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await n.addPluginListener("notification","notification",i)},i.pending=async function(){return await n.invoke("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await n.invoke("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await n.invoke("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await n.invoke("plugin:notification|remove_active")},i.removeChannel=async function(i){await n.invoke("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})}
if("__TAURI__"in window){var __TAURI_PLUGIN_NOTIFICATION__=function(i,n){"use strict";var e,t,o;i.ScheduleEvery=void 0,(e=i.ScheduleEvery||(i.ScheduleEvery={})).Year="year",e.Month="month",e.TwoWeeks="twoWeeks",e.Week="week",e.Day="day",e.Hour="hour",e.Minute="minute",e.Second="second";return i.Importance=void 0,(t=i.Importance||(i.Importance={}))[t.None=0]="None",t[t.Min=1]="Min",t[t.Low=2]="Low",t[t.Default=3]="Default",t[t.High=4]="High",i.Visibility=void 0,(o=i.Visibility||(i.Visibility={}))[o.Secret=-1]="Secret",o[o.Private=0]="Private",o[o.Public=1]="Public",i.Schedule=class{static at(i,n=!1,e=!1){return{at:{date:i,repeating:n,allowWhileIdle:e},interval:void 0,every:void 0}}static interval(i,n=!1){return{at:void 0,interval:{interval:i,allowWhileIdle:n},every:void 0}}static every(i,n,e=!1){return{at:void 0,interval:void 0,every:{interval:i,count:n,allowWhileIdle:e}}}},i.active=async function(){return await n.invoke("plugin:notification|get_active")},i.cancel=async function(i){await n.invoke("plugin:notification|cancel",{notifications:i})},i.cancelAll=async function(){await n.invoke("plugin:notification|cancel")},i.channels=async function(){return await n.invoke("plugin:notification|list_channels")},i.createChannel=async function(i){await n.invoke("plugin:notification|create_channel",{...i})},i.isPermissionGranted=async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):await n.invoke("plugin:notification|is_permission_granted")},i.onAction=async function(i){return await n.addPluginListener("notification","actionPerformed",i)},i.onNotificationReceived=async function(i){return await n.addPluginListener("notification","notification",i)},i.pending=async function(){return await n.invoke("plugin:notification|get_pending")},i.registerActionTypes=async function(i){await n.invoke("plugin:notification|register_action_types",{types:i})},i.removeActive=async function(i){await n.invoke("plugin:notification|remove_active",{notifications:i})},i.removeAllActive=async function(){await n.invoke("plugin:notification|remove_active")},i.removeChannel=async function(i){await n.invoke("plugin:notification|delete_channel",{id:i})},i.requestPermission=async function(){return await window.Notification.requestPermission()},i.sendNotification=function(i){"string"==typeof i?new window.Notification(i):new window.Notification(i.title,i)},i}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"notification",{value:__TAURI_PLUGIN_NOTIFICATION__})}
+1
View File
@@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[
"is_permission_granted",
"register_action_types",
"register_listener",
"remove_listener",
"cancel",
"get_pending",
"remove_active",
+1 -1
View File
@@ -879,7 +879,7 @@ async function removeChannel(id: string): Promise<void> {
* @since 2.0.0
*/
async function channels(): Promise<Channel[]> {
return await invoke('plugin:notification|listChannels')
return await invoke('plugin:notification|list_channels')
}
/**
@@ -120,7 +120,8 @@ func showNotification(invoke: Invoke, notification: Notification)
}
struct CancelArgs: Decodable {
let notifications: [Int]
// `cancelAll()` sends no list: cancel every pending notification
let notifications: [Int]?
}
struct Action: Decodable {
@@ -216,9 +217,13 @@ class NotificationPlugin: Plugin {
@objc func cancel(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(CancelArgs.self)
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: args.notifications.map { String($0) }
)
if let notifications = args.notifications {
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: notifications.map { String($0) }
)
} else {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
invoke.resolve()
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remove-listener"
description = "Enables the remove_listener command without any pre-configured scope."
commands.allow = ["remove_listener"]
[[permission]]
identifier = "deny-remove-listener"
description = "Denies the remove_listener command without any pre-configured scope."
commands.deny = ["remove_listener"]
@@ -14,6 +14,7 @@ It allows all notification related features.
- `allow-notify`
- `allow-register-action-types`
- `allow-register-listener`
- `allow-remove-listener`
- `allow-cancel`
- `allow-get-pending`
- `allow-remove-active`
@@ -402,6 +403,32 @@ Denies the remove_active command without any pre-configured scope.
<tr>
<td>
`notification:allow-remove-listener`
</td>
<td>
Enables the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`notification:deny-remove-listener`
</td>
<td>
Denies the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`notification:allow-request-permission`
</td>
@@ -16,6 +16,7 @@ permissions = [
"allow-notify",
"allow-register-action-types",
"allow-register-listener",
"allow-remove-listener",
"allow-cancel",
"allow-get-pending",
"allow-remove-active",
@@ -462,6 +462,18 @@
"const": "deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
@@ -487,10 +499,10 @@
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
}
]
}
+2
View File
@@ -287,6 +287,8 @@ mod imp {
use std::path::PathBuf;
pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> {
// these AppKit calls are only `unsafe` before objc2-app-kit 0.3.2
#[allow(unused_unsafe)]
unsafe {
let mut urls = Vec::new();
+63 -21
View File
@@ -156,6 +156,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.11.0
version: 2.11.0
'@tauri-apps/plugin-autostart':
specifier: workspace:*
version: link:../../plugins/autostart
'@tauri-apps/plugin-barcode-scanner':
specifier: workspace:*
version: link:../../plugins/barcode-scanner
@@ -168,6 +171,9 @@ importers:
'@tauri-apps/plugin-clipboard-manager':
specifier: workspace:*
version: link:../../plugins/clipboard-manager
'@tauri-apps/plugin-deep-link':
specifier: workspace:*
version: link:../../plugins/deep-link
'@tauri-apps/plugin-dialog':
specifier: workspace:*
version: link:../../plugins/dialog
@@ -198,21 +204,33 @@ importers:
'@tauri-apps/plugin-os':
specifier: workspace:*
version: link:../../plugins/os
'@tauri-apps/plugin-positioner':
specifier: workspace:*
version: link:../../plugins/positioner
'@tauri-apps/plugin-process':
specifier: workspace:*
version: link:../../plugins/process
'@tauri-apps/plugin-shell':
specifier: workspace:*
version: link:../../plugins/shell
'@tauri-apps/plugin-sql':
specifier: workspace:*
version: link:../../plugins/sql
'@tauri-apps/plugin-store':
specifier: workspace:*
version: link:../../plugins/store
'@tauri-apps/plugin-stronghold':
specifier: workspace:*
version: link:../../plugins/stronghold
'@tauri-apps/plugin-updater':
specifier: workspace:*
version: link:../../plugins/updater
'@tauri-apps/plugin-upload':
specifier: workspace:*
version: link:../../plugins/upload
'@tauri-apps/plugin-websocket':
specifier: workspace:*
version: link:../../plugins/websocket
'@zerodevx/svelte-json-view':
specifier: 2.0.0
version: 2.0.0(svelte@5.55.7(@typescript-eslint/types@8.70.0))
@@ -248,11 +266,14 @@ importers:
specifier: ^2.0.9
version: 2.0.9
'@crabnebula/test-runner-backend':
specifier: ^0.2.9
version: 0.2.9
specifier: ^0.3.0
version: 0.3.0
'@tauri-apps/api':
specifier: ^2.11.0
version: 2.11.0
'@tauri-apps/plugin-autostart':
specifier: workspace:*
version: link:../../plugins/autostart
'@tauri-apps/plugin-barcode-scanner':
specifier: workspace:*
version: link:../../plugins/barcode-scanner
@@ -265,6 +286,9 @@ importers:
'@tauri-apps/plugin-clipboard-manager':
specifier: workspace:*
version: link:../../plugins/clipboard-manager
'@tauri-apps/plugin-deep-link':
specifier: workspace:*
version: link:../../plugins/deep-link
'@tauri-apps/plugin-dialog':
specifier: workspace:*
version: link:../../plugins/dialog
@@ -298,27 +322,42 @@ importers:
'@tauri-apps/plugin-os':
specifier: workspace:*
version: link:../../plugins/os
'@tauri-apps/plugin-positioner':
specifier: workspace:*
version: link:../../plugins/positioner
'@tauri-apps/plugin-process':
specifier: workspace:*
version: link:../../plugins/process
'@tauri-apps/plugin-shell':
specifier: workspace:*
version: link:../../plugins/shell
'@tauri-apps/plugin-sql':
specifier: workspace:*
version: link:../../plugins/sql
'@tauri-apps/plugin-store':
specifier: workspace:*
version: link:../../plugins/store
'@tauri-apps/plugin-stronghold':
specifier: workspace:*
version: link:../../plugins/stronghold
'@tauri-apps/plugin-updater':
specifier: workspace:*
version: link:../../plugins/updater
'@tauri-apps/plugin-upload':
specifier: workspace:*
version: link:../../plugins/upload
'@tauri-apps/plugin-websocket':
specifier: workspace:*
version: link:../../plugins/websocket
'@tauri-apps/plugin-window-state':
specifier: workspace:*
version: link:../../plugins/window-state
'@types/node':
specifier: ^22.10.0
version: 22.20.3
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@wdio/appium-service':
specifier: ^9.31.9
version: 9.32.0(supports-color@8.1.1)
@@ -355,6 +394,9 @@ importers:
webdriverio:
specifier: ^9.31.9
version: 9.32.0(supports-color@8.1.1)
ws:
specifier: ^8.18.0
version: 8.21.3
plugins/autostart:
dependencies:
@@ -657,33 +699,33 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@crabnebula/test-runner-backend-darwin-arm64@0.2.9':
resolution: {integrity: sha512-UAlL8VA23y/fp+MigNYFwGvtE9vJ518f846c0k2vgT/bLIpt+d3UXndjZCMYbEVFTmaq00wHoRPSPbyzHEMWlQ==}
'@crabnebula/test-runner-backend-darwin-arm64@0.3.0':
resolution: {integrity: sha512-XnZdSeew2ew/Eix+z1BwKt5PcrJSjL+VQ7dlrV1fvRj4WUTQtluzE1VhiVV0j2EIFJsRH2IExNj5OdCWxKSPiQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@crabnebula/test-runner-backend-darwin-x64@0.2.9':
resolution: {integrity: sha512-RUP9L4Lk8KsT4A64U4QFVy5rj99Y6iIichPLpZgTvpRybYKZBVXjUiU31aGUIlWnd4VHsWKLM2vNl90rKZm3lA==}
'@crabnebula/test-runner-backend-darwin-x64@0.3.0':
resolution: {integrity: sha512-dpogk8TxKd2CZb8BZ2Ni1mpbogD8KSoB8J92aw+9R24S8xBeTu8I+t4w9PVNIBvFaNAhEQelYhFffxH0FDs5+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@crabnebula/test-runner-backend-linux-x64-gnu@0.2.9':
resolution: {integrity: sha512-js/haLE9OUsa0oDHNuVps5DY7av3iWG7PpAKjr9nMnIEXiLACDmvyUL/IplHMmu1lr6S0fztoudRf4JfTR+mfA==}
'@crabnebula/test-runner-backend-linux-x64-gnu@0.3.0':
resolution: {integrity: sha512-ZzDVKdY1ydv+lqfRYs6PAdmIc8AlfOciH3ntuUxyRJ8aSq0v8y+pow6b3/CRqmumkj9riVWsUriJr7Xw2YZFTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@crabnebula/test-runner-backend-win32-x64-msvc@0.2.9':
resolution: {integrity: sha512-p4pc/GQ0bj3vVLuUyTHDnhaXO52EQNkaCUPhWXwEkG00loW93pvCBRrOWoYpJH9me/n2zMFv61meW9mQyvbByw==}
'@crabnebula/test-runner-backend-win32-x64-msvc@0.3.0':
resolution: {integrity: sha512-xzE5bZx6QpqCkGFTpOYGxMfYidfsvYQgp3yQ1hXwuxeC5vsabOngRtE6X2DxQU6U8JzzNaooN0afG/khzoaihQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@crabnebula/test-runner-backend@0.2.9':
resolution: {integrity: sha512-3z6qZbJtVyA1JymQU5hFqcxG6nFD5INgqBc3OxTbYXVzMpihlRGOcHWyPK72ndHK88YmfwQWPcEFN1R/H/Lvhw==}
'@crabnebula/test-runner-backend@0.3.0':
resolution: {integrity: sha512-72aldFCPV0Sflv8KGLeaNoUY4Vz8eRl7AvUsJXFwqJ7KKNDAfPKwovXjmZoqIWn6Icv01sEsd1b2w0O66Y3WEQ==}
engines: {node: '>= 10'}
hasBin: true
@@ -4911,24 +4953,24 @@ snapshots:
'@crabnebula/tauri-driver-linux-x64-gnu': 2.0.9
'@crabnebula/tauri-driver-win32-x64-msvc': 2.0.9
'@crabnebula/test-runner-backend-darwin-arm64@0.2.9':
'@crabnebula/test-runner-backend-darwin-arm64@0.3.0':
optional: true
'@crabnebula/test-runner-backend-darwin-x64@0.2.9':
'@crabnebula/test-runner-backend-darwin-x64@0.3.0':
optional: true
'@crabnebula/test-runner-backend-linux-x64-gnu@0.2.9':
'@crabnebula/test-runner-backend-linux-x64-gnu@0.3.0':
optional: true
'@crabnebula/test-runner-backend-win32-x64-msvc@0.2.9':
'@crabnebula/test-runner-backend-win32-x64-msvc@0.3.0':
optional: true
'@crabnebula/test-runner-backend@0.2.9':
'@crabnebula/test-runner-backend@0.3.0':
optionalDependencies:
'@crabnebula/test-runner-backend-darwin-arm64': 0.2.9
'@crabnebula/test-runner-backend-darwin-x64': 0.2.9
'@crabnebula/test-runner-backend-linux-x64-gnu': 0.2.9
'@crabnebula/test-runner-backend-win32-x64-msvc': 0.2.9
'@crabnebula/test-runner-backend-darwin-arm64': 0.3.0
'@crabnebula/test-runner-backend-darwin-x64': 0.3.0
'@crabnebula/test-runner-backend-linux-x64-gnu': 0.3.0
'@crabnebula/test-runner-backend-win32-x64-msvc': 0.3.0
'@dabh/diagnostics@2.0.9':
dependencies: