diff --git a/.changes/fix-notification-mobile-permissions.md b/.changes/fix-notification-mobile-permissions.md new file mode 100644 index 000000000..8a8174a03 --- /dev/null +++ b/.changes/fix-notification-mobile-permissions.md @@ -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. diff --git a/.github/workflows/test-api-e2e-mobile.yml b/.github/workflows/test-api-e2e-mobile.yml index 301a5a09c..c73cb89c6 100644 --- a/.github/workflows/test-api-e2e-mobile.yml +++ b/.github/workflows/test-api-e2e-mobile.yml @@ -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 diff --git a/.github/workflows/test-api-e2e.yml b/.github/workflows/test-api-e2e.yml index f5316a362..3c18ad54b 100644 --- a/.github/workflows/test-api-e2e.yml +++ b/.github/workflows/test-api-e2e.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 1830efd8e..f06cbed1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index d297e4f71..23e38cbac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/examples/api/package.json b/examples/api/package.json index 98ed4b6ab..c788209c6 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -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": { diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 24f576f9f..db762fce2 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -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" } diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 773c0ec58..90c6d976a 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -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" ] } diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json index 425ecb973..26722c2fd 100644 --- a/examples/api/src-tauri/capabilities/desktop.json +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -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", diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 11a375684..9a20067df 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -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() diff --git a/examples/api/src-tauri/src/tray.rs b/examples/api/src-tauri/src/tray.rs index 7b1321a57..ba4a5a003 100644 --- a/examples/api/src-tauri/src/tray.rs +++ b/examples/api/src-tauri/src/tray.rs @@ -107,6 +107,8 @@ pub fn create_tray(app: &tauri::AppHandle) -> 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, diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte index 00b914810..573ff7e19 100644 --- a/examples/api/src/App.svelte +++ b/examples/api/src/App.svelte @@ -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, diff --git a/examples/api/src/views/Autostart.svelte b/examples/api/src/views/Autostart.svelte new file mode 100644 index 000000000..1f8c557d3 --- /dev/null +++ b/examples/api/src/views/Autostart.svelte @@ -0,0 +1,30 @@ + + +
+ + Launching at login is {enabled ? 'enabled' : 'disabled'} +
diff --git a/examples/api/src/views/DeepLink.svelte b/examples/api/src/views/DeepLink.svelte new file mode 100644 index 000000000..9a1a19153 --- /dev/null +++ b/examples/api/src/views/DeepLink.svelte @@ -0,0 +1,67 @@ + + +
+
+ Current deep link: {current ? current.join(', ') : 'none'} +
+ +
+ + + + +
+
diff --git a/examples/api/src/views/Positioner.svelte b/examples/api/src/views/Positioner.svelte new file mode 100644 index 000000000..1f79167c1 --- /dev/null +++ b/examples/api/src/views/Positioner.svelte @@ -0,0 +1,40 @@ + + +
+

+ The tray positions only resolve once the tray icon has been clicked, which + reports its location to the plugin. +

+
+ + + +
+
diff --git a/examples/api/src/views/Sql.svelte b/examples/api/src/views/Sql.svelte new file mode 100644 index 000000000..45e656e44 --- /dev/null +++ b/examples/api/src/views/Sql.svelte @@ -0,0 +1,68 @@ + + +
+
+ + +
+ + {#each todos as todo (todo.id)} +
+ toggle(todo)} + /> + {todo.title} + +
+ {/each} +
diff --git a/examples/api/src/views/Stronghold.svelte b/examples/api/src/views/Stronghold.svelte new file mode 100644 index 000000000..900b2bbba --- /dev/null +++ b/examples/api/src/views/Stronghold.svelte @@ -0,0 +1,71 @@ + + +
+ {#if stronghold} +
+ + + +
+ {:else} +
+ + +
+ {/if} +
diff --git a/examples/api/src/views/WebSocket.svelte b/examples/api/src/views/WebSocket.svelte new file mode 100644 index 000000000..9a91f89ab --- /dev/null +++ b/examples/api/src/views/WebSocket.svelte @@ -0,0 +1,55 @@ + + +
+ {#if ws} +
+ + + +
+ {:else} +
+ + +
+ {/if} +
diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index b5e63929d..e39fc1781 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -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 diff --git a/packages/api-e2e/package.json b/packages/api-e2e/package.json index f0e29eac6..b8226b538 100644 --- a/packages/api-e2e/package.json +++ b/packages/api-e2e/package.json @@ -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" } } diff --git a/packages/api-e2e/test/helpers/index.ts b/packages/api-e2e/test/helpers/index.ts index 8177f5bba..6da724c30 100644 --- a/packages/api-e2e/test/helpers/index.ts +++ b/packages/api-e2e/test/helpers/index.ts @@ -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 /** diff --git a/packages/api-e2e/test/helpers/server.ts b/packages/api-e2e/test/helpers/server.ts index 1c0f9d649..204f74107 100644 --- a/packages/api-e2e/test/helpers/server.ts +++ b/packages/api-e2e/test/helpers/server.ts @@ -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 { const server = http.createServer((req, res) => { @@ -91,12 +103,39 @@ export function startFixtureServer(): Promise { }) }) + 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() } diff --git a/packages/api-e2e/test/specs/autostart.spec.ts b/packages/api-e2e/test/specs/autostart.spec.ts new file mode 100644 index 000000000..20a09a00c --- /dev/null +++ b/packages/api-e2e/test/specs/autostart.spec.ts @@ -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 }) + }) +}) diff --git a/packages/api-e2e/test/specs/clipboard-manager.spec.ts b/packages/api-e2e/test/specs/clipboard-manager.spec.ts index e4be7c59a..f5bf0e428 100644 --- a/packages/api-e2e/test/specs/clipboard-manager.spec.ts +++ b/packages/api-e2e/test/specs/clipboard-manager.spec.ts @@ -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 () => { diff --git a/packages/api-e2e/test/specs/deep-link.spec.ts b/packages/api-e2e/test/specs/deep-link.spec.ts new file mode 100644 index 000000000..a818e8f17 --- /dev/null +++ b/packages/api-e2e/test/specs/deep-link.spec.ts @@ -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) + } + } + ) +}) diff --git a/packages/api-e2e/test/specs/fs.spec.ts b/packages/api-e2e/test/specs/fs.spec.ts index 5f1b39154..d590e0395 100644 --- a/packages/api-e2e/test/specs/fs.spec.ts +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -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) } ) diff --git a/packages/api-e2e/test/specs/global-shortcut.spec.ts b/packages/api-e2e/test/specs/global-shortcut.spec.ts index c0b0980ee..89aa30076 100644 --- a/packages/api-e2e/test/specs/global-shortcut.spec.ts +++ b/packages/api-e2e/test/specs/global-shortcut.spec.ts @@ -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 () => { diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts index 9ccfbc656..cd283200a 100644 --- a/packages/api-e2e/test/specs/http.spec.ts +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -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/) }) }) diff --git a/packages/api-e2e/test/specs/notification.spec.ts b/packages/api-e2e/test/specs/notification.spec.ts index d4d95476f..c67256c44 100644 --- a/packages/api-e2e/test/specs/notification.spec.ts +++ b/packages/api-e2e/test/specs/notification.spec.ts @@ -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' + }) + } + ) }) diff --git a/packages/api-e2e/test/specs/opener.spec.ts b/packages/api-e2e/test/specs/opener.spec.ts index 27201c0c8..d5e1b733c 100644 --- a/packages/api-e2e/test/specs/opener.spec.ts +++ b/packages/api-e2e/test/specs/opener.spec.ts @@ -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/) }) }) diff --git a/packages/api-e2e/test/specs/os.spec.ts b/packages/api-e2e/test/specs/os.spec.ts index fcad729df..82484752e 100644 --- a/packages/api-e2e/test/specs/os.spec.ts +++ b/packages/api-e2e/test/specs/os.spec.ts @@ -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. diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts index 79a97a287..7fdc00997 100644 --- a/packages/api-e2e/test/specs/plugins.spec.ts +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -31,6 +31,13 @@ const commonSurface: Surface = { 'readImage', 'writeImage' ], + deepLink: [ + 'getCurrent', + 'register', + 'unregister', + 'isRegistered', + 'onOpenUrl' + ], dialog: ['open', 'save', 'message', 'ask', 'confirm'], fs: [ 'BaseDirectory', @@ -98,14 +105,26 @@ const commonSurface: Surface = { ], 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 = { + 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)[plugin] - !== 'object' - ), + plugins.filter((plugin) => { + // eslint-disable-next-line security/detect-object-injection + const global = (api as unknown as Record)[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([]) diff --git a/packages/api-e2e/test/specs/positioner.spec.ts b/packages/api-e2e/test/specs/positioner.spec.ts new file mode 100644 index 000000000..f2cfadf9f --- /dev/null +++ b/packages/api-e2e/test/specs/positioner.spec.ts @@ -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 + }) + }) +}) diff --git a/packages/api-e2e/test/specs/shell.spec.ts b/packages/api-e2e/test/specs/shell.spec.ts index bfdedacd8..d43ce3020 100644 --- a/packages/api-e2e/test/specs/shell.spec.ts +++ b/packages/api-e2e/test/specs/shell.spec.ts @@ -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/) }) }) diff --git a/packages/api-e2e/test/specs/sql.spec.ts b/packages/api-e2e/test/specs/sql.spec.ts new file mode 100644 index 000000000..f20f28a31 --- /dev/null +++ b/packages/api-e2e/test/specs/sql.spec.ts @@ -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('SELECT * FROM e2e ORDER BY id'), + filtered: await database.select( + 'SELECT name FROM e2e WHERE score = $1', + [42] + ), + none: await database.select( + '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[]>( + '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/) + }) +}) diff --git a/packages/api-e2e/test/specs/stronghold.spec.ts b/packages/api-e2e/test/specs/stronghold.spec.ts new file mode 100644 index 000000000..4a4960125 --- /dev/null +++ b/packages/api-e2e/test/specs/stronghold.spec.ts @@ -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) + }) +}) diff --git a/packages/api-e2e/test/specs/upload.spec.ts b/packages/api-e2e/test/specs/upload.spec.ts index 6c98b9cfb..eaf6c1d85 100644 --- a/packages/api-e2e/test/specs/upload.spec.ts +++ b/packages/api-e2e/test/specs/upload.spec.ts @@ -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/) }) }) diff --git a/packages/api-e2e/test/specs/websocket.spec.ts b/packages/api-e2e/test/specs/websocket.spec.ts new file mode 100644 index 000000000..dece2e3fe --- /dev/null +++ b/packages/api-e2e/test/specs/websocket.spec.ts @@ -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((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((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((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 + }, `${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((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((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((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/) + }) +}) diff --git a/packages/api-e2e/wdio.mobile.ts b/packages/api-e2e/wdio.mobile.ts index cadb15883..815802ac8 100644 --- a/packages/api-e2e/wdio.mobile.ts +++ b/packages/api-e2e/wdio.mobile.ts @@ -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 ` 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 { 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 diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index 3ead31527..19a82833d 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -39,7 +39,8 @@ class BatchArgs { @InvokeArg class CancelArgs { - lateinit var notifications: List + // `cancelAll()` sends no list: cancel every pending notification + var notifications: List? = 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>() 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( + "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() 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) } diff --git a/plugins/notification/api-iife.js b/plugins/notification/api-iife.js index 8eb7ac0e7..85f75b279 100644 --- a/plugins/notification/api-iife.js +++ b/plugins/notification/api-iife.js @@ -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__})} diff --git a/plugins/notification/build.rs b/plugins/notification/build.rs index 4b24c755e..d7c0b3ed5 100644 --- a/plugins/notification/build.rs +++ b/plugins/notification/build.rs @@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[ "is_permission_granted", "register_action_types", "register_listener", + "remove_listener", "cancel", "get_pending", "remove_active", diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index ee9c24c91..534635f14 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -879,7 +879,7 @@ async function removeChannel(id: string): Promise { * @since 2.0.0 */ async function channels(): Promise { - return await invoke('plugin:notification|listChannels') + return await invoke('plugin:notification|list_channels') } /** diff --git a/plugins/notification/ios/Sources/NotificationPlugin.swift b/plugins/notification/ios/Sources/NotificationPlugin.swift index 6d8391bcc..8adbff9f5 100644 --- a/plugins/notification/ios/Sources/NotificationPlugin.swift +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -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() } diff --git a/plugins/notification/permissions/autogenerated/commands/remove_listener.toml b/plugins/notification/permissions/autogenerated/commands/remove_listener.toml new file mode 100644 index 000000000..9f315e51e --- /dev/null +++ b/plugins/notification/permissions/autogenerated/commands/remove_listener.toml @@ -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"] diff --git a/plugins/notification/permissions/autogenerated/reference.md b/plugins/notification/permissions/autogenerated/reference.md index b0652545a..c79a441d8 100644 --- a/plugins/notification/permissions/autogenerated/reference.md +++ b/plugins/notification/permissions/autogenerated/reference.md @@ -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. +`notification:allow-remove-listener` + + + + +Enables the remove_listener command without any pre-configured scope. + + + + + + + +`notification:deny-remove-listener` + + + + +Denies the remove_listener command without any pre-configured scope. + + + + + + + `notification:allow-request-permission` diff --git a/plugins/notification/permissions/default.toml b/plugins/notification/permissions/default.toml index 00b4e1d0c..76d9e2757 100644 --- a/plugins/notification/permissions/default.toml +++ b/plugins/notification/permissions/default.toml @@ -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", diff --git a/plugins/notification/permissions/schemas/schema.json b/plugins/notification/permissions/schemas/schema.json index 26703a8a1..bea41b7e3 100644 --- a/plugins/notification/permissions/schemas/schema.json +++ b/plugins/notification/permissions/schemas/schema.json @@ -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`" } ] } diff --git a/plugins/opener/src/reveal_item_in_dir.rs b/plugins/opener/src/reveal_item_in_dir.rs index b5da19060..9578a2823 100644 --- a/plugins/opener/src/reveal_item_in_dir.rs +++ b/plugins/opener/src/reveal_item_in_dir.rs @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4315a0d..f445e0f2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: