From 92b01ea05020e431a13ab96a1c0402be7877279e Mon Sep 17 00:00:00 2001 From: Fabian-Lars Date: Thu, 27 Apr 2023 12:23:49 +0200 Subject: [PATCH 01/14] refactor(fs-watch): make options arg optional, closes #3 (#334) * refactor(fs-watch): make options arg optional, closes #3 * fmt * readme --- plugins/fs-watch/README.md | 8 ++++---- plugins/fs-watch/guest-js/index.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/fs-watch/README.md b/plugins/fs-watch/README.md index 8162d1fe..59729b4d 100644 --- a/plugins/fs-watch/README.md +++ b/plugins/fs-watch/README.md @@ -56,18 +56,18 @@ import { watch, watchImmediate } from "tauri-plugin-fs-watch-api"; // can also watch an array of paths const stopWatching = await watch( "/path/to/something", - { recursive: true }, (event) => { const { type, payload } = event; - } + }, + { recursive: true } ); const stopRawWatcher = await watchImmediate( ["/path/a", "/path/b"], - {}, (event) => { const { path, operation, cookie } = event; - } + }, + {} ); ``` diff --git a/plugins/fs-watch/guest-js/index.ts b/plugins/fs-watch/guest-js/index.ts index 31d333b2..05ed07e5 100644 --- a/plugins/fs-watch/guest-js/index.ts +++ b/plugins/fs-watch/guest-js/index.ts @@ -44,8 +44,8 @@ async function unwatch(id: number): Promise { export async function watch( paths: string | string[], - options: DebouncedWatchOptions, - cb: (event: DebouncedEvent) => void + cb: (event: DebouncedEvent) => void, + options: DebouncedWatchOptions = {} ): Promise { const opts = { recursive: false, @@ -82,8 +82,8 @@ export async function watch( export async function watchImmediate( paths: string | string[], - options: WatchOptions, - cb: (event: RawEvent) => void + cb: (event: RawEvent) => void, + options: WatchOptions = {} ): Promise { const opts = { recursive: false, From 46f4a949c93fbefcbf9d0e2d77e72d637876669d Mon Sep 17 00:00:00 2001 From: Fabian-Lars Date: Thu, 27 Apr 2023 12:52:29 +0200 Subject: [PATCH 02/14] docs(positioner): Fix npm package name, fixes #6 --- plugins/positioner/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/positioner/README.md b/plugins/positioner/README.md index ebac8500..1fb9a01c 100644 --- a/plugins/positioner/README.md +++ b/plugins/positioner/README.md @@ -30,11 +30,11 @@ You can install the JavaScript Guest bindings using your preferred JavaScript pa > Note: Since most JavaScript package managers are unable to install packages from git monorepos we provide read-only mirrors of each plugin. This makes installation option 2 more ergonomic to use. ```sh -pnpm add tauri-plugin-positioner +pnpm add tauri-plugin-positioner-api # or -npm add tauri-plugin-positioner +npm add tauri-plugin-positioner-api # or -yarn add tauri-plugin-positioner +yarn add tauri-plugin-positioner-api ``` Or through git: From 8de442113f51bf0016fd8cddb71eeba354d3e6d9 Mon Sep 17 00:00:00 2001 From: Fabian-Lars Date: Fri, 28 Apr 2023 21:20:10 +0200 Subject: [PATCH 03/14] fix(ws): Don't block main thread while connecting (#337) --- plugins/websocket/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/websocket/src/lib.rs b/plugins/websocket/src/lib.rs index c3e08ace..1f06da32 100644 --- a/plugins/websocket/src/lib.rs +++ b/plugins/websocket/src/lib.rs @@ -79,15 +79,14 @@ enum WebSocketMessage { } #[tauri::command] -fn connect( +async fn connect( window: Window, url: String, callback_function: CallbackFn, config: Option, ) -> Result { let id = rand::random(); - let (ws_stream, _) = - tauri::async_runtime::block_on(connect_async_with_config(url, config.map(Into::into)))?; + let (ws_stream, _) = connect_async_with_config(url, config.map(Into::into)).await?; tauri::async_runtime::spawn(async move { let (write, read) = ws_stream.split(); From d02432df065eaf9510d08f67bcff90cf618b6098 Mon Sep 17 00:00:00 2001 From: Fabian-Lars Date: Sun, 30 Apr 2023 17:02:25 +0200 Subject: [PATCH 04/14] fix(log): Replace unknown js location with `webview::unknown`, fixes #41 (#338) --- plugins/log/guest-js/index.ts | 7 ++++++- plugins/log/src/lib.rs | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/log/guest-js/index.ts b/plugins/log/guest-js/index.ts index bf67c16b..f421b7bc 100644 --- a/plugins/log/guest-js/index.ts +++ b/plugins/log/guest-js/index.ts @@ -52,10 +52,15 @@ async function log( const { file, line, ...keyValues } = options ?? {}; + let location = filtered?.[0]?.filter((v) => v.length > 0).join("@"); + if (location === "Error") { + location = "webview::unknown"; + } + await invoke("plugin:log|log", { level, message, - location: filtered?.[0]?.filter((v) => v.length > 0).join("@"), + location, file, line, keyValues, diff --git a/plugins/log/src/lib.rs b/plugins/log/src/lib.rs index 5f40cab0..9db4aa4e 100644 --- a/plugins/log/src/lib.rs +++ b/plugins/log/src/lib.rs @@ -146,8 +146,8 @@ fn log( let location = location.unwrap_or("webview"); let mut builder = RecordBuilder::new(); builder - .target(location) .level(level.into()) + .target(location) .file(file) .line(line); @@ -178,8 +178,8 @@ impl Default for Builder { out.finish(format_args!( "{}[{}][{}] {}", DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(), - record.target(), record.level(), + record.target(), message )) }); @@ -213,8 +213,8 @@ impl Builder { out.finish(format_args!( "{}[{}][{}] {}", timezone_strategy.get_now().format(&format).unwrap(), - record.target(), record.level(), + record.target(), message )) }); @@ -273,8 +273,8 @@ impl Builder { out.finish(format_args!( "{}[{}][{}] {}", timezone_strategy.get_now().format(&format).unwrap(), - record.target(), colors.color(record.level()), + record.target(), message )) }) From dabacbe64412aca09c1bb6f167e9c287e263dd45 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Tue, 2 May 2023 13:40:57 -0300 Subject: [PATCH 05/14] fix(http): adjust client id argument name on `request` command usage --- plugins/http/guest-js/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index 655a339f..c8028648 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -338,7 +338,7 @@ class Client { options.responseType = ResponseType.Text } return invoke>('plugin:http|request', { - client: this.id, + clientId: this.id, options }).then((res) => { const response = new Response(res) From 882fcf8ab13b2851faf92f10df692afa90ce1371 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Tue, 2 May 2023 09:42:52 -0700 Subject: [PATCH 06/14] feat(ci): update covector getPublishedVersion (#339) --- .changes/config.json | 4 +- .scripts/covector/package-latest-version.js | 54 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 .scripts/covector/package-latest-version.js diff --git a/.changes/config.json b/.changes/config.json index d527385d..8de61c38 100644 --- a/.changes/config.json +++ b/.changes/config.json @@ -3,12 +3,12 @@ "pkgManagers": { "javascript": { "version": true, - "getPublishedVersion": "pnpm view ${ pkgFile.pkg.name } version", + "getPublishedVersion": "node ../../.scripts/covector/package-latest-version.js npm ${ pkgFile.pkg.name } ${ pkgFile.pkg.version }", "publish": ["pnpm build", "pnpm publish --access public --no-git-checks"] }, "rust": { "version": true, - "getPublishedVersion": "cargo search ${ pkgFile.pkg.package.name } --limit 1 | sed -nE 's/^[^\"]*\"//; s/\".*//1p' -", + "getPublishedVersion": "node ../../.scripts/covector/package-latest-version.js cargo ${ pkgFile.pkg.package.name } ${ pkgFile.pkg.package.version }", "publish": [ { "command": "cargo package --no-verify", diff --git a/.scripts/covector/package-latest-version.js b/.scripts/covector/package-latest-version.js new file mode 100644 index 00000000..d7ec15ad --- /dev/null +++ b/.scripts/covector/package-latest-version.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/* +This script is solely intended to be run as part of the `covector publish` step to +check the latest version of a crate, considering the current minor version. +*/ + +const https = require('https') + +const kind = process.argv[2] +const packageName = process.argv[3] +const packageVersion = process.argv[4] +const target = packageVersion.substring(0, packageVersion.lastIndexOf('.')) + +let url = null +switch (kind) { + case 'cargo': + url = `https://crates.io/api/v1/crates/${packageName}` + break; + case 'npm': + url = `https://registry.npmjs.org/${packageName}` + break; + default: + throw new Error('unexpected kind ' + kind) +} + +const options = { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'tauri (https://github.com/tauri-apps/tauri)' + } +} + +https.get(url, options, (response) => { + let chunks = [] + response.on('data', function (chunk) { + chunks.push(chunk) + }) + + response.on('end', function () { + const data = JSON.parse(chunks.join('')) + if (kind === 'cargo') { + const versions = data.versions.filter(v => v.num.startsWith(target)) + console.log(versions.length ? versions[0].num : '0.0.0') + } else if (kind === 'npm') { + const versions = Object.keys(data.versions).filter(v => v.startsWith(target)) + console.log(versions[versions.length - 1] || '0.0.0') + } + }) +}) From 9244d4ee8f840865556fe77bcda1b80a74513f56 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Tue, 2 May 2023 13:46:56 -0300 Subject: [PATCH 07/14] fix(store): adjust `load` and `save` implementation --- plugins/store/src/store.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/store/src/store.rs b/plugins/store/src/store.rs index f8ba51a9..004b05b9 100644 --- a/plugins/store/src/store.rs +++ b/plugins/store/src/store.rs @@ -175,8 +175,9 @@ pub struct Store { impl Store { /// Update the store from the on-disk state - pub fn load(&mut self, app: &AppHandle) -> Result<(), Error> { - let app_dir = app + pub fn load(&mut self) -> Result<(), Error> { + let app_dir = self + .app .path() .app_data_dir() .expect("failed to resolve app dir"); @@ -191,8 +192,9 @@ impl Store { } /// Saves the store to disk - pub fn save(&self, app: &AppHandle) -> Result<(), Error> { - let app_dir = app + pub fn save(&self) -> Result<(), Error> { + let app_dir = self + .app .path() .app_data_dir() .expect("failed to resolve app dir"); From dce0f02bc571128308c30278cde3233f341e6a50 Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 08:55:44 +0200 Subject: [PATCH 08/14] Rename dev branch to v1 and next branch to v2 --- .github/workflows/audit-javascript.yml | 6 ++++-- .github/workflows/audit-rust.yml | 6 ++++-- .github/workflows/covector-version-or-publish.yml | 2 +- .github/workflows/lint-javascript.yml | 6 ++++-- .github/workflows/lint-rust.yml | 8 +++++--- .github/workflows/msrv-check.yml | 8 +++++--- .github/workflows/sync.yml | 4 ++-- plugins/authenticator/README.md | 2 +- plugins/autostart/README.md | 2 +- plugins/fs-extra/README.md | 2 +- plugins/fs-watch/README.md | 2 +- plugins/localhost/README.md | 2 +- plugins/log/README.md | 2 +- plugins/persisted-scope/README.md | 2 +- plugins/positioner/README.md | 2 +- plugins/single-instance/README.md | 2 +- plugins/sql/README.md | 2 +- plugins/store/README.md | 2 +- plugins/stronghold/README.md | 2 +- plugins/upload/README.md | 2 +- plugins/websocket/README.md | 2 +- plugins/window-state/README.md | 2 +- shared/template/README.md | 2 +- 23 files changed, 41 insertions(+), 31 deletions(-) diff --git a/.github/workflows/audit-javascript.yml b/.github/workflows/audit-javascript.yml index bd84610d..19a7f4c8 100644 --- a/.github/workflows/audit-javascript.yml +++ b/.github/workflows/audit-javascript.yml @@ -6,14 +6,16 @@ on: - cron: "0 0 * * *" push: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/audit-javascript.yml" - "**/pnpm-lock.yaml" - "**/package.json" pull_request: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/audit-javascript.yml" - "**/pnpm-lock.yaml" diff --git a/.github/workflows/audit-rust.yml b/.github/workflows/audit-rust.yml index 7a824fa1..1ec0225e 100644 --- a/.github/workflows/audit-rust.yml +++ b/.github/workflows/audit-rust.yml @@ -6,14 +6,16 @@ on: - cron: "0 0 * * *" push: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/audit-rust.yml" - "**/Cargo.lock" - "**/Cargo.toml" pull_request: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/audit-rust.yml" - "**/Cargo.lock" diff --git a/.github/workflows/covector-version-or-publish.yml b/.github/workflows/covector-version-or-publish.yml index 14057c07..b5c4ab7d 100644 --- a/.github/workflows/covector-version-or-publish.yml +++ b/.github/workflows/covector-version-or-publish.yml @@ -3,7 +3,7 @@ name: version or publish on: push: branches: - - dev + - v1 jobs: version-or-publish: diff --git a/.github/workflows/lint-javascript.yml b/.github/workflows/lint-javascript.yml index 1483f288..c2c0a1d3 100644 --- a/.github/workflows/lint-javascript.yml +++ b/.github/workflows/lint-javascript.yml @@ -3,7 +3,8 @@ name: Lint JavaScript on: push: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/lint-javascript.yml" - "plugins/*/guest-js/**" @@ -13,7 +14,8 @@ on: - "**/package.json" pull_request: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/lint-javascript.yml" - "plugins/*/guest-js/**" diff --git a/.github/workflows/lint-rust.yml b/.github/workflows/lint-rust.yml index da353a56..f724e838 100644 --- a/.github/workflows/lint-rust.yml +++ b/.github/workflows/lint-rust.yml @@ -3,14 +3,16 @@ name: Lint Rust on: push: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/lint-rust.yml" - "plugins/*/src/**" - "**/Cargo.toml" pull_request: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/lint-rust.yml" - "plugins/*/src/**" @@ -32,7 +34,7 @@ jobs: - name: install webkit2gtk and libudev for [authenticator] run: | sudo apt-get update - sudo apt-get install -y webkit2gtk-4.0 libudev-dev + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libudev-dev - name: Install clippy with stable toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/msrv-check.yml b/.github/workflows/msrv-check.yml index a603e8f0..807bbcad 100644 --- a/.github/workflows/msrv-check.yml +++ b/.github/workflows/msrv-check.yml @@ -3,7 +3,8 @@ name: Check MSRV on: push: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/msrv-check.yml" - "plugins/*/src/**" @@ -11,7 +12,8 @@ on: - "**/Cargo.lock" pull_request: branches: - - dev + - v1 + - v2 paths: - ".github/workflows/msrv-check.yml" - "plugins/*/src/**" @@ -34,7 +36,7 @@ jobs: - name: install webkit2gtk and libudev for [authenticator] run: | sudo apt-get update - sudo apt-get install -y webkit2gtk-4.0 libudev-dev + sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libudev-dev - uses: dtolnay/rust-toolchain@1.64.0 diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 6b42bf53..82da46fc 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -4,8 +4,8 @@ on: workflow_dispatch: push: branches: - - dev - - next + - v1 + - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/plugins/authenticator/README.md b/plugins/authenticator/README.md index 0e700f6a..89fa6e29 100644 --- a/plugins/authenticator/README.md +++ b/plugins/authenticator/README.md @@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: [dependencies] tauri-plugin-authenticator = "0.1" # or through git -tauri-plugin-authenticator = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-authenticator = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/autostart/README.md b/plugins/autostart/README.md index 419907c3..7c9f8ddb 100644 --- a/plugins/autostart/README.md +++ b/plugins/autostart/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/fs-extra/README.md b/plugins/fs-extra/README.md index 98d311fe..3eeaf003 100644 --- a/plugins/fs-extra/README.md +++ b/plugins/fs-extra/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/fs-watch/README.md b/plugins/fs-watch/README.md index 59729b4d..29f6cc11 100644 --- a/plugins/fs-watch/README.md +++ b/plugins/fs-watch/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/localhost/README.md b/plugins/localhost/README.md index 50665f66..3fd7797a 100644 --- a/plugins/localhost/README.md +++ b/plugins/localhost/README.md @@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } portpicker = "0.1" # used in the example to pick a random free port ``` diff --git a/plugins/log/README.md b/plugins/log/README.md index fca43611..e4326f38 100644 --- a/plugins/log/README.md +++ b/plugins/log/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/persisted-scope/README.md b/plugins/persisted-scope/README.md index 26888b59..4c38450a 100644 --- a/plugins/persisted-scope/README.md +++ b/plugins/persisted-scope/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` ## Usage diff --git a/plugins/positioner/README.md b/plugins/positioner/README.md index 1fb9a01c..464e3cfb 100644 --- a/plugins/positioner/README.md +++ b/plugins/positioner/README.md @@ -22,7 +22,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: [dependencies] tauri-plugin-positioner = "1.0" # or through git -tauri-plugin-positioner = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-positioner = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/single-instance/README.md b/plugins/single-instance/README.md index 595d6b17..7b265c59 100644 --- a/plugins/single-instance/README.md +++ b/plugins/single-instance/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` ## Usage diff --git a/plugins/sql/README.md b/plugins/sql/README.md index 7ec807b1..2522848d 100644 --- a/plugins/sql/README.md +++ b/plugins/sql/README.md @@ -19,7 +19,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies.tauri-plugin-sql] git = "https://github.com/tauri-apps/plugins-workspace" -branch = "dev" +branch = "v1" features = ["sqlite"] # or "postgres", or "mysql" ``` diff --git a/plugins/store/README.md b/plugins/store/README.md index f8ff874f..4aef4a96 100644 --- a/plugins/store/README.md +++ b/plugins/store/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/stronghold/README.md b/plugins/stronghold/README.md index 2b5b2790..5c486b96 100644 --- a/plugins/stronghold/README.md +++ b/plugins/stronghold/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/upload/README.md b/plugins/upload/README.md index cd24a9d5..b69c8eda 100644 --- a/plugins/upload/README.md +++ b/plugins/upload/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/websocket/README.md b/plugins/websocket/README.md index 96c5c324..7c34af4a 100644 --- a/plugins/websocket/README.md +++ b/plugins/websocket/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/plugins/window-state/README.md b/plugins/window-state/README.md index 90c0a0bb..02a1bdd1 100644 --- a/plugins/window-state/README.md +++ b/plugins/window-state/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] -tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } +tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: diff --git a/shared/template/README.md b/shared/template/README.md index ed5545a2..413618b9 100644 --- a/shared/template/README.md +++ b/shared/template/README.md @@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file: ```toml [dependencies] - = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } + = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } ``` You can install the JavaScript Guest bindings using your preferred JavaScript package manager: From 2184b72de0cd0d05b61dbadd873fb4b73e64995e Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 09:11:45 +0200 Subject: [PATCH 09/14] run formatter on covector script --- .scripts/covector/package-latest-version.js | 62 +++++++++++---------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/.scripts/covector/package-latest-version.js b/.scripts/covector/package-latest-version.js index d7ec15ad..9ff107c3 100644 --- a/.scripts/covector/package-latest-version.js +++ b/.scripts/covector/package-latest-version.js @@ -8,47 +8,49 @@ This script is solely intended to be run as part of the `covector publish` step check the latest version of a crate, considering the current minor version. */ -const https = require('https') +const https = require("https"); -const kind = process.argv[2] -const packageName = process.argv[3] -const packageVersion = process.argv[4] -const target = packageVersion.substring(0, packageVersion.lastIndexOf('.')) +const kind = process.argv[2]; +const packageName = process.argv[3]; +const packageVersion = process.argv[4]; +const target = packageVersion.substring(0, packageVersion.lastIndexOf(".")); -let url = null +let url = null; switch (kind) { - case 'cargo': - url = `https://crates.io/api/v1/crates/${packageName}` + case "cargo": + url = `https://crates.io/api/v1/crates/${packageName}`; break; - case 'npm': - url = `https://registry.npmjs.org/${packageName}` + case "npm": + url = `https://registry.npmjs.org/${packageName}`; break; default: - throw new Error('unexpected kind ' + kind) + throw new Error("unexpected kind " + kind); } const options = { headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'tauri (https://github.com/tauri-apps/tauri)' - } -} + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "tauri (https://github.com/tauri-apps/tauri)", + }, +}; https.get(url, options, (response) => { - let chunks = [] - response.on('data', function (chunk) { - chunks.push(chunk) - }) + let chunks = []; + response.on("data", function (chunk) { + chunks.push(chunk); + }); - response.on('end', function () { - const data = JSON.parse(chunks.join('')) - if (kind === 'cargo') { - const versions = data.versions.filter(v => v.num.startsWith(target)) - console.log(versions.length ? versions[0].num : '0.0.0') - } else if (kind === 'npm') { - const versions = Object.keys(data.versions).filter(v => v.startsWith(target)) - console.log(versions[versions.length - 1] || '0.0.0') + response.on("end", function () { + const data = JSON.parse(chunks.join("")); + if (kind === "cargo") { + const versions = data.versions.filter((v) => v.num.startsWith(target)); + console.log(versions.length ? versions[0].num : "0.0.0"); + } else if (kind === "npm") { + const versions = Object.keys(data.versions).filter((v) => + v.startsWith(target) + ); + console.log(versions[versions.length - 1] || "0.0.0"); } - }) -}) + }); +}); From b730c34923b1da5b7af472757598e1c9765b8c6e Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 09:13:24 +0200 Subject: [PATCH 10/14] Run formatter on new plugins --- plugins/cli/guest-js/index.ts | 22 +- plugins/clipboard/guest-js/index.ts | 31 ++- plugins/clipboard/package.json | 2 +- .../dialog/examples/tauri-app/package.json | 2 +- .../AppIcon.appiconset/Contents.json | 154 +++++------ .../gen/apple/Assets.xcassets/Contents.json | 8 +- .../tauri-app/src-tauri/gen/apple/project.yml | 4 +- .../tauri-app/src-tauri/tauri.conf.json | 2 +- .../dialog/examples/tauri-app/vite.config.js | 18 +- plugins/dialog/guest-js/index.ts | 118 ++++----- plugins/fs/guest-js/index.ts | 137 +++++----- plugins/global-shortcut/guest-js/index.ts | 34 +-- plugins/http/guest-js/index.ts | 230 ++++++++-------- plugins/notification/guest-js/index.ts | 28 +- plugins/notification/src/init.js | 74 +++--- plugins/shell/guest-js/index.ts | 248 +++++++++--------- plugins/shell/package.json | 2 +- tsconfig.base.json | 36 +-- 18 files changed, 582 insertions(+), 568 deletions(-) diff --git a/plugins/cli/guest-js/index.ts b/plugins/cli/guest-js/index.ts index 1472f084..81eaae86 100644 --- a/plugins/cli/guest-js/index.ts +++ b/plugins/cli/guest-js/index.ts @@ -8,7 +8,7 @@ * @module */ -import { invoke } from '@tauri-apps/api/tauri'; +import { invoke } from "@tauri-apps/api/tauri"; /** * @since 1.0.0 @@ -19,32 +19,32 @@ interface ArgMatch { * boolean if flag * string[] or null if takes multiple values */ - value: string | boolean | string[] | null + value: string | boolean | string[] | null; /** * Number of occurrences */ - occurrences: number + occurrences: number; } /** * @since 1.0.0 */ interface SubcommandMatch { - name: string - matches: CliMatches + name: string; + matches: CliMatches; } /** * @since 1.0.0 */ interface CliMatches { - args: Record - subcommand: SubcommandMatch | null + args: Record; + subcommand: SubcommandMatch | null; } /** * Parse the arguments provided to the current process and get the matches using the configuration defined [`tauri.cli`](https://tauri.app/v1/api/config/#tauriconfig.cli) in `tauri.conf.json` - * + * * @example * ```typescript * import { getMatches } from 'tauri-plugin-cli-api'; @@ -64,9 +64,9 @@ interface CliMatches { * @since 1.0.0 */ async function getMatches(): Promise { - return await invoke('plugin:cli|cli_matches'); + return await invoke("plugin:cli|cli_matches"); } -export type { ArgMatch, SubcommandMatch, CliMatches } +export type { ArgMatch, SubcommandMatch, CliMatches }; -export { getMatches } +export { getMatches }; diff --git a/plugins/clipboard/guest-js/index.ts b/plugins/clipboard/guest-js/index.ts index 443532ca..3dd2a67f 100644 --- a/plugins/clipboard/guest-js/index.ts +++ b/plugins/clipboard/guest-js/index.ts @@ -24,14 +24,14 @@ * @module */ -import { invoke } from '@tauri-apps/api/tauri' +import { invoke } from "@tauri-apps/api/tauri"; interface Clip { - kind: K - options: T + kind: K; + options: T; } -type ClipResponse = Clip<'PlainText', string> +type ClipResponse = Clip<"PlainText", string>; /** * Writes plain text to the clipboard. @@ -46,16 +46,19 @@ type ClipResponse = Clip<'PlainText', string> * * @since 1.0.0. */ -async function writeText(text: string, opts?: { label?: string }): Promise { - return invoke('plugin:clipboard|write', { +async function writeText( + text: string, + opts?: { label?: string } +): Promise { + return invoke("plugin:clipboard|write", { data: { - kind: 'PlainText', + kind: "PlainText", options: { label: opts?.label, - text - } - } - }) + text, + }, + }, + }); } /** @@ -68,8 +71,8 @@ async function writeText(text: string, opts?: { label?: string }): Promise * @since 1.0.0. */ async function readText(): Promise { - const kind: ClipResponse = await invoke('plugin:clipboard|read') - return kind.options + const kind: ClipResponse = await invoke("plugin:clipboard|read"); + return kind.options; } -export { writeText, readText } +export { writeText, readText }; diff --git a/plugins/clipboard/package.json b/plugins/clipboard/package.json index 299a8d6f..d51585f4 100644 --- a/plugins/clipboard/package.json +++ b/plugins/clipboard/package.json @@ -29,4 +29,4 @@ "dependencies": { "@tauri-apps/api": "^1.2.0" } -} \ No newline at end of file +} diff --git a/plugins/dialog/examples/tauri-app/package.json b/plugins/dialog/examples/tauri-app/package.json index 2236136a..39488aee 100644 --- a/plugins/dialog/examples/tauri-app/package.json +++ b/plugins/dialog/examples/tauri-app/package.json @@ -19,4 +19,4 @@ "svelte": "^3.49.0", "vite": "^3.0.2" } -} \ No newline at end of file +} diff --git a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json index 90eea7ec..dd3b8bcc 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,116 +1,116 @@ { - "images" : [ + "images": [ { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "AppIcon-20x20@2x.png", - "scale" : "2x" + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@2x.png", + "scale": "2x" }, { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "AppIcon-20x20@3x.png", - "scale" : "3x" + "size": "20x20", + "idiom": "iphone", + "filename": "AppIcon-20x20@3x.png", + "scale": "3x" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "AppIcon-29x29@2x-1.png", - "scale" : "2x" + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@2x-1.png", + "scale": "2x" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "AppIcon-29x29@3x.png", - "scale" : "3x" + "size": "29x29", + "idiom": "iphone", + "filename": "AppIcon-29x29@3x.png", + "scale": "3x" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "AppIcon-40x40@2x.png", - "scale" : "2x" + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@2x.png", + "scale": "2x" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "AppIcon-40x40@3x.png", - "scale" : "3x" + "size": "40x40", + "idiom": "iphone", + "filename": "AppIcon-40x40@3x.png", + "scale": "3x" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "AppIcon-60x60@2x.png", - "scale" : "2x" + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@2x.png", + "scale": "2x" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "AppIcon-60x60@3x.png", - "scale" : "3x" + "size": "60x60", + "idiom": "iphone", + "filename": "AppIcon-60x60@3x.png", + "scale": "3x" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "AppIcon-20x20@1x.png", - "scale" : "1x" + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@1x.png", + "scale": "1x" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "AppIcon-20x20@2x-1.png", - "scale" : "2x" + "size": "20x20", + "idiom": "ipad", + "filename": "AppIcon-20x20@2x-1.png", + "scale": "2x" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "AppIcon-29x29@1x.png", - "scale" : "1x" + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@1x.png", + "scale": "1x" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "AppIcon-29x29@2x.png", - "scale" : "2x" + "size": "29x29", + "idiom": "ipad", + "filename": "AppIcon-29x29@2x.png", + "scale": "2x" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "AppIcon-40x40@1x.png", - "scale" : "1x" + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@1x.png", + "scale": "1x" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "AppIcon-40x40@2x-1.png", - "scale" : "2x" + "size": "40x40", + "idiom": "ipad", + "filename": "AppIcon-40x40@2x-1.png", + "scale": "2x" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "AppIcon-76x76@1x.png", - "scale" : "1x" + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@1x.png", + "scale": "1x" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "AppIcon-76x76@2x.png", - "scale" : "2x" + "size": "76x76", + "idiom": "ipad", + "filename": "AppIcon-76x76@2x.png", + "scale": "2x" }, { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "AppIcon-83.5x83.5@2x.png", - "scale" : "2x" + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "AppIcon-83.5x83.5@2x.png", + "scale": "2x" }, { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "AppIcon-512@2x.png", - "scale" : "1x" + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "AppIcon-512@2x.png", + "scale": "1x" } ], - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "version": 1, + "author": "xcode" } -} \ No newline at end of file +} diff --git a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/Contents.json b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/Contents.json index da4a164c..97a8662e 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/Contents.json +++ b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "version": 1, + "author": "xcode" } -} \ No newline at end of file +} diff --git a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml index c416a2ed..838c4894 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml +++ b/plugins/dialog/examples/tauri-app/src-tauri/gen/apple/project.yml @@ -60,7 +60,7 @@ targets: base: ENABLE_BITCODE: false ARCHS: [arm64, arm64-sim] - VALID_ARCHS: arm64 arm64-sim + VALID_ARCHS: arm64 arm64-sim LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) @@ -82,4 +82,4 @@ targets: basedOnDependencyAnalysis: false outputFiles: - $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/libapp.a - - $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libapp.a \ No newline at end of file + - $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libapp.a diff --git a/plugins/dialog/examples/tauri-app/src-tauri/tauri.conf.json b/plugins/dialog/examples/tauri-app/src-tauri/tauri.conf.json index 31ef704f..b6fb480a 100644 --- a/plugins/dialog/examples/tauri-app/src-tauri/tauri.conf.json +++ b/plugins/dialog/examples/tauri-app/src-tauri/tauri.conf.json @@ -63,4 +63,4 @@ } ] } -} \ No newline at end of file +} diff --git a/plugins/dialog/examples/tauri-app/vite.config.js b/plugins/dialog/examples/tauri-app/vite.config.js index b7427654..8d30f660 100644 --- a/plugins/dialog/examples/tauri-app/vite.config.js +++ b/plugins/dialog/examples/tauri-app/vite.config.js @@ -4,7 +4,11 @@ import { internalIpV4 } from "internal-ip"; // https://vitejs.dev/config/ export default defineConfig(async () => { - const host = process.env.TAURI_PLATFORM === 'android' || process.env.TAURI_PLATFORM === 'ios' ? (await internalIpV4()) : 'localhost' + const host = + process.env.TAURI_PLATFORM === "android" || + process.env.TAURI_PLATFORM === "ios" + ? await internalIpV4() + : "localhost"; return { plugins: [svelte()], @@ -13,17 +17,17 @@ export default defineConfig(async () => { clearScreen: false, // tauri expects a fixed port, fail if that port is not available server: { - host: '0.0.0.0', + host: "0.0.0.0", port: 5173, strictPort: true, hmr: { - protocol: 'ws', + protocol: "ws", host, - port: 5183 + port: 5183, }, fs: { - allow: ['.', '../../tooling/api/dist'] - } + allow: [".", "../../tooling/api/dist"], + }, }, // to make use of `TAURI_DEBUG` and other env variables // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand @@ -36,5 +40,5 @@ export default defineConfig(async () => { // produce sourcemaps for debug builds sourcemap: !!process.env.TAURI_DEBUG, }, - } + }; }); diff --git a/plugins/dialog/guest-js/index.ts b/plugins/dialog/guest-js/index.ts index 09270f6e..50c6e64c 100644 --- a/plugins/dialog/guest-js/index.ts +++ b/plugins/dialog/guest-js/index.ts @@ -2,18 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -import { invoke } from '@tauri-apps/api/tauri' +import { invoke } from "@tauri-apps/api/tauri"; interface FileResponse { - base64Data?: string - duration?: number - height?: number - width?: number - mimeType?: string - modifiedAt?: number - name?: string - path: string - size: number + base64Data?: string; + duration?: number; + height?: number; + width?: number; + mimeType?: string; + modifiedAt?: number; + name?: string; + path: string; + size: number; } /** @@ -23,7 +23,7 @@ interface FileResponse { */ interface DialogFilter { /** Filter name. */ - name: string + name: string; /** * Extensions to filter, without a `.` prefix. * @example @@ -31,7 +31,7 @@ interface DialogFilter { * extensions: ['svg', 'png'] * ``` */ - extensions: string[] + extensions: string[]; } /** @@ -41,20 +41,20 @@ interface DialogFilter { */ interface OpenDialogOptions { /** The title of the dialog window. */ - title?: string + title?: string; /** The filters of the dialog. */ - filters?: DialogFilter[] + filters?: DialogFilter[]; /** Initial directory or file path. */ - defaultPath?: string + defaultPath?: string; /** Whether the dialog allows multiple selection or not. */ - multiple?: boolean + multiple?: boolean; /** Whether the dialog is a directory selection or not. */ - directory?: boolean + directory?: boolean; /** * If `directory` is true, indicates that it will be read recursively later. * Defines whether subdirectories will be allowed on the scope or not. */ - recursive?: boolean + recursive?: boolean; } /** @@ -64,15 +64,15 @@ interface OpenDialogOptions { */ interface SaveDialogOptions { /** The title of the dialog window. */ - title?: string + title?: string; /** The filters of the dialog. */ - filters?: DialogFilter[] + filters?: DialogFilter[]; /** * Initial directory or file path. * If it's a directory path, the dialog interface will change to that folder. * If it's not an existing directory, the file name will be set to the dialog's file name input and the dialog will be set to the parent folder. */ - defaultPath?: string + defaultPath?: string; } /** @@ -80,36 +80,36 @@ interface SaveDialogOptions { */ interface MessageDialogOptions { /** The title of the dialog. Defaults to the app name. */ - title?: string + title?: string; /** The type of the dialog. Defaults to `info`. */ - type?: 'info' | 'warning' | 'error' + type?: "info" | "warning" | "error"; /** The label of the confirm button. */ - okLabel?: string + okLabel?: string; } interface ConfirmDialogOptions { /** The title of the dialog. Defaults to the app name. */ - title?: string + title?: string; /** The type of the dialog. Defaults to `info`. */ - type?: 'info' | 'warning' | 'error' + type?: "info" | "warning" | "error"; /** The label of the confirm button. */ - okLabel?: string + okLabel?: string; /** The label of the cancel button. */ - cancelLabel?: string + cancelLabel?: string; } async function open( - options?: OpenDialogOptions & { multiple?: false, directory?: false } -): Promise + options?: OpenDialogOptions & { multiple?: false; directory?: false } +): Promise; async function open( - options?: OpenDialogOptions & { multiple?: true, directory?: false } -): Promise + options?: OpenDialogOptions & { multiple?: true; directory?: false } +): Promise; async function open( - options?: OpenDialogOptions & { multiple?: false, directory?: true } -): Promise + options?: OpenDialogOptions & { multiple?: false; directory?: true } +): Promise; async function open( - options?: OpenDialogOptions & { multiple?: true, directory?: true } -): Promise + options?: OpenDialogOptions & { multiple?: true; directory?: true } +): Promise; /** * Open a file/directory selection dialog. * @@ -165,11 +165,11 @@ async function open( async function open( options: OpenDialogOptions = {} ): Promise { - if (typeof options === 'object') { - Object.freeze(options) + if (typeof options === "object") { + Object.freeze(options); } - return invoke('plugin:dialog|open', { options }) + return invoke("plugin:dialog|open", { options }); } /** @@ -197,11 +197,11 @@ async function open( * @since 1.0.0 */ async function save(options: SaveDialogOptions = {}): Promise { - if (typeof options === 'object') { - Object.freeze(options) + if (typeof options === "object") { + Object.freeze(options); } - return invoke('plugin:dialog|save', { options }) + return invoke("plugin:dialog|save", { options }); } /** @@ -225,13 +225,13 @@ async function message( message: string, options?: string | MessageDialogOptions ): Promise { - const opts = typeof options === 'string' ? { title: options } : options - return invoke('plugin:dialog|message', { + const opts = typeof options === "string" ? { title: options } : options; + return invoke("plugin:dialog|message", { message: message.toString(), title: opts?.title?.toString(), type_: opts?.type, - okButtonLabel: opts?.okLabel?.toString() - }) + okButtonLabel: opts?.okLabel?.toString(), + }); } /** @@ -254,14 +254,14 @@ async function ask( message: string, options?: string | ConfirmDialogOptions ): Promise { - const opts = typeof options === 'string' ? { title: options } : options - return invoke('plugin:dialog|ask', { + const opts = typeof options === "string" ? { title: options } : options; + return invoke("plugin:dialog|ask", { message: message.toString(), title: opts?.title?.toString(), type_: opts?.type, - okButtonLabel: opts?.okLabel?.toString() ?? 'Yes', - cancelButtonLabel: opts?.cancelLabel?.toString() ?? 'No', - }) + okButtonLabel: opts?.okLabel?.toString() ?? "Yes", + cancelButtonLabel: opts?.cancelLabel?.toString() ?? "No", + }); } /** @@ -284,14 +284,14 @@ async function confirm( message: string, options?: string | ConfirmDialogOptions ): Promise { - const opts = typeof options === 'string' ? { title: options } : options - return invoke('plugin:dialog|confirm', { + const opts = typeof options === "string" ? { title: options } : options; + return invoke("plugin:dialog|confirm", { message: message.toString(), title: opts?.title?.toString(), type_: opts?.type, - okButtonLabel: opts?.okLabel?.toString() ?? 'Ok', - cancelButtonLabel: opts?.cancelLabel?.toString() ?? 'Cancel', - }) + okButtonLabel: opts?.okLabel?.toString() ?? "Ok", + cancelButtonLabel: opts?.cancelLabel?.toString() ?? "Cancel", + }); } export type { @@ -299,7 +299,7 @@ export type { OpenDialogOptions, SaveDialogOptions, MessageDialogOptions, - ConfirmDialogOptions -} + ConfirmDialogOptions, +}; -export { open, save, message, ask, confirm } +export { open, save, message, ask, confirm }; diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index 4201ac4a..66dd3591 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -210,14 +210,14 @@ enum BaseDirectory { Font, Home, Runtime, - Template + Template, } /** * @since 1.0.0 */ interface FsOptions { - dir?: BaseDirectory + dir?: BaseDirectory; // note that adding fields here needs a change in the writeBinaryFile check } @@ -225,8 +225,8 @@ interface FsOptions { * @since 1.0.0 */ interface FsDirOptions { - dir?: BaseDirectory - recursive?: boolean + dir?: BaseDirectory; + recursive?: boolean; } /** @@ -236,12 +236,12 @@ interface FsDirOptions { */ interface FsTextFileOption { /** Path to the file to write. */ - path: string + path: string; /** The UTF-8 string to write to the file. */ - contents: string + contents: string; } -type BinaryFileContents = Iterable | ArrayLike | ArrayBuffer +type BinaryFileContents = Iterable | ArrayLike | ArrayBuffer; /** * Options object used to write a binary data to a file. @@ -250,23 +250,23 @@ type BinaryFileContents = Iterable | ArrayLike | ArrayBuffer */ interface FsBinaryFileOption { /** Path to the file to write. */ - path: string + path: string; /** The byte array contents. */ - contents: BinaryFileContents + contents: BinaryFileContents; } /** * @since 1.0.0 */ interface FileEntry { - path: string + path: string; /** * Name of the directory/file * can be null if the path terminates with `..` */ - name?: string + name?: string; /** Children of this entry if it's a directory; null otherwise */ - children?: FileEntry[] + children?: FileEntry[]; } /** @@ -286,8 +286,8 @@ async function readTextFile( ): Promise { return await invoke("plugin:fs|read_text_file", { path: filePath, - options - }) + options, + }); } /** @@ -307,10 +307,10 @@ async function readBinaryFile( ): Promise { const arr = await invoke("plugin:fs|read_file", { path: filePath, - options - }) + options, + }); - return Uint8Array.from(arr) + return Uint8Array.from(arr); } /** @@ -328,7 +328,7 @@ async function writeTextFile( path: string, contents: string, options?: FsOptions -): Promise +): Promise; /** * Writes a UTF-8 text file. @@ -345,7 +345,7 @@ async function writeTextFile( async function writeTextFile( file: FsTextFileOption, options?: FsOptions -): Promise +): Promise; /** * Writes a UTF-8 text file. @@ -359,33 +359,33 @@ async function writeTextFile( contents?: string | FsOptions, options?: FsOptions ): Promise { - if (typeof options === 'object') { - Object.freeze(options) + if (typeof options === "object") { + Object.freeze(options); } - if (typeof path === 'object') { - Object.freeze(path) + if (typeof path === "object") { + Object.freeze(path); } - const file: FsTextFileOption = { path: '', contents: '' } - let fileOptions: FsOptions | undefined = options - if (typeof path === 'string') { - file.path = path + const file: FsTextFileOption = { path: "", contents: "" }; + let fileOptions: FsOptions | undefined = options; + if (typeof path === "string") { + file.path = path; } else { - file.path = path.path - file.contents = path.contents + file.path = path.path; + file.contents = path.contents; } - if (typeof contents === 'string') { - file.contents = contents ?? '' + if (typeof contents === "string") { + file.contents = contents ?? ""; } else { - fileOptions = contents + fileOptions = contents; } return await invoke("plugin:fs|write_file", { path: file.path, contents: Array.from(new TextEncoder().encode(file.contents)), - options: fileOptions - }) + options: fileOptions, + }); } /** @@ -406,7 +406,7 @@ async function writeBinaryFile( path: string, contents: BinaryFileContents, options?: FsOptions -): Promise +): Promise; /** * Writes a byte array content to a file. @@ -426,7 +426,7 @@ async function writeBinaryFile( async function writeBinaryFile( file: FsBinaryFileOption, options?: FsOptions -): Promise +): Promise; /** * Writes a byte array content to a file. @@ -440,27 +440,27 @@ async function writeBinaryFile( contents?: BinaryFileContents | FsOptions, options?: FsOptions ): Promise { - if (typeof options === 'object') { - Object.freeze(options) + if (typeof options === "object") { + Object.freeze(options); } - if (typeof path === 'object') { - Object.freeze(path) + if (typeof path === "object") { + Object.freeze(path); } - const file: FsBinaryFileOption = { path: '', contents: [] } - let fileOptions: FsOptions | undefined = options - if (typeof path === 'string') { - file.path = path + const file: FsBinaryFileOption = { path: "", contents: [] }; + let fileOptions: FsOptions | undefined = options; + if (typeof path === "string") { + file.path = path; } else { - file.path = path.path - file.contents = path.contents + file.path = path.path; + file.contents = path.contents; } - if (contents && 'dir' in contents) { - fileOptions = contents - } else if (typeof path === 'string') { + if (contents && "dir" in contents) { + fileOptions = contents; + } else if (typeof path === "string") { // @ts-expect-error in this case `contents` is always a BinaryFileContents - file.contents = contents ?? [] + file.contents = contents ?? []; } return await invoke("plugin:fs|write_binary_file", { @@ -470,8 +470,8 @@ async function writeBinaryFile( ? new Uint8Array(file.contents) : file.contents ), - options: fileOptions - }) + options: fileOptions, + }); } /** @@ -500,8 +500,8 @@ async function readDir( ): Promise { return await invoke("plugin:fs|read_dir", { path: dir, - options - }) + options, + }); } /** @@ -525,8 +525,8 @@ async function createDir( ): Promise { return await invoke("plugin:fs|create_dir", { path: dir, - options - }) + options, + }); } /** @@ -549,8 +549,8 @@ async function removeDir( ): Promise { return await invoke("plugin:fs|remove_dir", { path: dir, - options - }) + options, + }); } /** @@ -574,8 +574,8 @@ async function copyFile( return await invoke("plugin:fs|copy_file", { source, destination, - options - }) + options, + }); } /** @@ -597,8 +597,8 @@ async function removeFile( ): Promise { return await invoke("plugin:fs|remove_file", { path: file, - options - }) + options, + }); } /** @@ -622,9 +622,8 @@ async function renameFile( return await invoke("plugin:fs|rename_file", { oldPath, newPath, - options - - }) + options, + }); } /** @@ -644,7 +643,7 @@ async function exists(path: string): Promise { /** * Returns the metadata for the given path. - * + * * @since 1.0.0 */ async function metadata(path: string): Promise { @@ -670,7 +669,7 @@ export type { FileEntry, Permissions, Metadata, -} +}; export { BaseDirectory, @@ -687,5 +686,5 @@ export { removeFile, renameFile, exists, - metadata -} \ No newline at end of file + metadata, +}; diff --git a/plugins/global-shortcut/guest-js/index.ts b/plugins/global-shortcut/guest-js/index.ts index 4d4966c3..c2c66756 100644 --- a/plugins/global-shortcut/guest-js/index.ts +++ b/plugins/global-shortcut/guest-js/index.ts @@ -21,9 +21,9 @@ * @module */ -import { invoke, transformCallback } from '@tauri-apps/api/tauri' +import { invoke, transformCallback } from "@tauri-apps/api/tauri"; -export type ShortcutHandler = (shortcut: string) => void +export type ShortcutHandler = (shortcut: string) => void; /** * Register a global shortcut. @@ -44,10 +44,10 @@ async function register( shortcut: string, handler: ShortcutHandler ): Promise { - return await invoke('plugin:globalShortcut|register', { + return await invoke("plugin:globalShortcut|register", { shortcut, - handler: transformCallback(handler) - }) + handler: transformCallback(handler), + }); } /** @@ -69,15 +69,15 @@ async function registerAll( shortcuts: string[], handler: ShortcutHandler ): Promise { - return await invoke('plugin:globalShortcut|register_all', { + return await invoke("plugin:globalShortcut|register_all", { shortcuts, - handler: transformCallback(handler) - }) + handler: transformCallback(handler), + }); } /** * Determines whether the given shortcut is registered by this application or not. - * + * * If the shortcut is registered by another application, it will still return `false`. * * @example @@ -91,9 +91,9 @@ async function registerAll( * @since 1.0.0 */ async function isRegistered(shortcut: string): Promise { - return await invoke('plugin:globalShortcut|is_registered', { - shortcut - }) + return await invoke("plugin:globalShortcut|is_registered", { + shortcut, + }); } /** @@ -109,9 +109,9 @@ async function isRegistered(shortcut: string): Promise { * @since 1.0.0 */ async function unregister(shortcut: string): Promise { - return await invoke('plugin:globalShortcut|unregister', { - shortcut - }) + return await invoke("plugin:globalShortcut|unregister", { + shortcut, + }); } /** @@ -125,7 +125,7 @@ async function unregister(shortcut: string): Promise { * @since 1.0.0 */ async function unregisterAll(): Promise { - return await invoke('plugin:globalShortcut|unregister_all') + return await invoke("plugin:globalShortcut|unregister_all"); } -export { register, registerAll, isRegistered, unregister, unregisterAll } +export { register, registerAll, isRegistered, unregister, unregisterAll }; diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index c8028648..47e5e323 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -41,14 +41,14 @@ * @module */ -import { invoke } from '@tauri-apps/api/tauri' +import { invoke } from "@tauri-apps/api/tauri"; /** * @since 1.0.0 */ interface Duration { - secs: number - nanos: number + secs: number; + nanos: number; } /** @@ -59,8 +59,8 @@ interface ClientOptions { * Defines the maximum number of redirects the client should follow. * If set to 0, no redirects will be followed. */ - maxRedirections?: number - connectTimeout?: number | Duration + maxRedirections?: number; + connectTimeout?: number | Duration; } /** @@ -69,19 +69,19 @@ interface ClientOptions { enum ResponseType { JSON = 1, Text = 2, - Binary = 3 + Binary = 3, } /** * @since 1.0.0 */ interface FilePart { - file: string | T - mime?: string - fileName?: string + file: string | T; + mime?: string; + fileName?: string; } -type Part = string | Uint8Array | FilePart +type Part = string | Uint8Array | FilePart; /** * The body object to be used on POST and PUT requests. @@ -89,13 +89,13 @@ type Part = string | Uint8Array | FilePart * @since 1.0.0 */ class Body { - type: string - payload: unknown + type: string; + payload: unknown; /** @ignore */ private constructor(type: string, payload: unknown) { - this.type = type - this.payload = payload + this.type = type; + this.payload = payload; } /** @@ -130,39 +130,39 @@ class Body { * @returns The body object ready to be used on the POST and PUT requests. */ static form(data: Record | FormData): Body { - const form: Record> = {} + const form: Record> = {}; const append = ( key: string, v: string | Uint8Array | FilePart | File ): void => { if (v !== null) { - let r - if (typeof v === 'string') { - r = v + let r; + if (typeof v === "string") { + r = v; } else if (v instanceof Uint8Array || Array.isArray(v)) { - r = Array.from(v) + r = Array.from(v); } else if (v instanceof File) { - r = { file: v.name, mime: v.type, fileName: v.name } - } else if (typeof v.file === 'string') { - r = { file: v.file, mime: v.mime, fileName: v.fileName } + r = { file: v.name, mime: v.type, fileName: v.name }; + } else if (typeof v.file === "string") { + r = { file: v.file, mime: v.mime, fileName: v.fileName }; } else { - r = { file: Array.from(v.file), mime: v.mime, fileName: v.fileName } + r = { file: Array.from(v.file), mime: v.mime, fileName: v.fileName }; } - form[String(key)] = r + form[String(key)] = r; } - } + }; if (data instanceof FormData) { for (const [key, value] of data) { - append(key, value) + append(key, value); } } else { for (const [key, value] of Object.entries(data)) { - append(key, value) + append(key, value); } } - return new Body('Form', form) + return new Body("Form", form); } /** @@ -181,7 +181,7 @@ class Body { * @returns The body object ready to be used on the POST and PUT requests. */ static json(data: Record): Body { - return new Body('Json', data) + return new Body("Json", data); } /** @@ -197,7 +197,7 @@ class Body { * @returns The body object ready to be used on the POST and PUT requests. */ static text(value: string): Body { - return new Body('Text', value) + return new Body("Text", value); } /** @@ -217,23 +217,23 @@ class Body { ): Body { // stringifying Uint8Array doesn't return an array of numbers, so we create one here return new Body( - 'Bytes', + "Bytes", Array.from(bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes) - ) + ); } } /** The request HTTP verb. */ type HttpVerb = - | 'GET' - | 'POST' - | 'PUT' - | 'DELETE' - | 'PATCH' - | 'HEAD' - | 'OPTIONS' - | 'CONNECT' - | 'TRACE' + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + | "HEAD" + | "OPTIONS" + | "CONNECT" + | "TRACE"; /** * Options object sent to the backend. @@ -241,27 +241,27 @@ type HttpVerb = * @since 1.0.0 */ interface HttpOptions { - method: HttpVerb - url: string - headers?: Record - query?: Record - body?: Body - timeout?: number | Duration - responseType?: ResponseType + method: HttpVerb; + url: string; + headers?: Record; + query?: Record; + body?: Body; + timeout?: number | Duration; + responseType?: ResponseType; } /** Request options. */ -type RequestOptions = Omit +type RequestOptions = Omit; /** Options for the `fetch` API. */ -type FetchOptions = Omit +type FetchOptions = Omit; /** @ignore */ interface IResponse { - url: string - status: number - headers: Record - rawHeaders: Record - data: T + url: string; + status: number; + headers: Record; + rawHeaders: Record; + data: T; } /** @@ -271,26 +271,26 @@ interface IResponse { * */ class Response { /** The request URL. */ - url: string + url: string; /** The response status code. */ - status: number + status: number; /** A boolean indicating whether the response was successful (status in the range 200–299) or not. */ - ok: boolean + ok: boolean; /** The response headers. */ - headers: Record + headers: Record; /** The response raw headers. */ - rawHeaders: Record + rawHeaders: Record; /** The response data. */ - data: T + data: T; /** @ignore */ constructor(response: IResponse) { - this.url = response.url - this.status = response.status - this.ok = this.status >= 200 && this.status < 300 - this.headers = response.headers - this.rawHeaders = response.rawHeaders - this.data = response.data + this.url = response.url; + this.status = response.status; + this.ok = this.status >= 200 && this.status < 300; + this.headers = response.headers; + this.rawHeaders = response.rawHeaders; + this.data = response.data; } } @@ -298,10 +298,10 @@ class Response { * @since 1.0.0 */ class Client { - id: number + id: number; /** @ignore */ constructor(id: number) { - this.id = id + this.id = id; } /** @@ -314,9 +314,9 @@ class Client { * ``` */ async drop(): Promise { - return invoke('plugin:http|drop_client', { - client: this.id - }) + return invoke("plugin:http|drop_client", { + client: this.id, + }); } /** @@ -333,34 +333,34 @@ class Client { */ async request(options: HttpOptions): Promise> { const jsonResponse = - !options.responseType || options.responseType === ResponseType.JSON + !options.responseType || options.responseType === ResponseType.JSON; if (jsonResponse) { - options.responseType = ResponseType.Text + options.responseType = ResponseType.Text; } - return invoke>('plugin:http|request', { + return invoke>("plugin:http|request", { clientId: this.id, - options + options, }).then((res) => { - const response = new Response(res) + const response = new Response(res); if (jsonResponse) { /* eslint-disable */ try { - response.data = JSON.parse(response.data as string) + response.data = JSON.parse(response.data as string); } catch (e) { - if (response.ok && (response.data as unknown as string) === '') { - response.data = {} as T + if (response.ok && (response.data as unknown as string) === "") { + response.data = {} as T; } else if (response.ok) { throw Error( `Failed to parse response \`${response.data}\` as JSON: ${e}; try setting the \`responseType\` option to \`ResponseType.Text\` or \`ResponseType.Binary\` if the API does not return a JSON response.` - ) + ); } } /* eslint-enable */ - return response + return response; } - return response - }) + return response; + }); } /** @@ -378,10 +378,10 @@ class Client { */ async get(url: string, options?: RequestOptions): Promise> { return this.request({ - method: 'GET', + method: "GET", url, - ...options - }) + ...options, + }); } /** @@ -406,11 +406,11 @@ class Client { options?: RequestOptions ): Promise> { return this.request({ - method: 'POST', + method: "POST", url, body, - ...options - }) + ...options, + }); } /** @@ -436,11 +436,11 @@ class Client { options?: RequestOptions ): Promise> { return this.request({ - method: 'PUT', + method: "PUT", url, body, - ...options - }) + ...options, + }); } /** @@ -456,10 +456,10 @@ class Client { */ async patch(url: string, options?: RequestOptions): Promise> { return this.request({ - method: 'PATCH', + method: "PATCH", url, - ...options - }) + ...options, + }); } /** @@ -473,10 +473,10 @@ class Client { */ async delete(url: string, options?: RequestOptions): Promise> { return this.request({ - method: 'DELETE', + method: "DELETE", url, - ...options - }) + ...options, + }); } } @@ -495,13 +495,13 @@ class Client { * @since 1.0.0 */ async function getClient(options?: ClientOptions): Promise { - return invoke('plugin:http|create_client', { - options - }).then((id) => new Client(id)) + return invoke("plugin:http|create_client", { + options, + }).then((id) => new Client(id)); } /** @internal */ -let defaultClient: Client | null = null +let defaultClient: Client | null = null; /** * Perform an HTTP request using the default client. @@ -519,13 +519,13 @@ async function fetch( options?: FetchOptions ): Promise> { if (defaultClient === null) { - defaultClient = await getClient() + defaultClient = await getClient(); } return defaultClient.request({ url, - method: options?.method ?? 'GET', - ...options - }) + method: options?.method ?? "GET", + ...options, + }); } export type { @@ -535,7 +535,15 @@ export type { HttpVerb, HttpOptions, RequestOptions, - FetchOptions -} - -export { getClient, fetch, Body, Client, Response, ResponseType, type FilePart } + FetchOptions, +}; + +export { + getClient, + fetch, + Body, + Client, + Response, + ResponseType, + type FilePart, +}; diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index a480105b..7a186795 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -24,7 +24,7 @@ * @module */ -import { invoke } from '@tauri-apps/api/tauri' +import { invoke } from "@tauri-apps/api/tauri"; /** * Options to send a notification. @@ -33,15 +33,15 @@ import { invoke } from '@tauri-apps/api/tauri' */ interface Options { /** Notification title. */ - title: string + title: string; /** Optional notification body. */ - body?: string + body?: string; /** Optional notification icon. */ - icon?: string + icon?: string; } /** Possible permission values. */ -type Permission = 'granted' | 'denied' | 'default' +type Permission = "granted" | "denied" | "default"; /** * Checks if the permission to send notifications is granted. @@ -54,10 +54,10 @@ type Permission = 'granted' | 'denied' | 'default' * @since 1.0.0 */ async function isPermissionGranted(): Promise { - if (window.Notification.permission !== 'default') { - return Promise.resolve(window.Notification.permission === 'granted') + if (window.Notification.permission !== "default") { + return Promise.resolve(window.Notification.permission === "granted"); } - return invoke('plugin:notification|is_permission_granted') + return invoke("plugin:notification|is_permission_granted"); } /** @@ -77,7 +77,7 @@ async function isPermissionGranted(): Promise { * @since 1.0.0 */ async function requestPermission(): Promise { - return window.Notification.requestPermission() + return window.Notification.requestPermission(); } /** @@ -99,15 +99,15 @@ async function requestPermission(): Promise { * @since 1.0.0 */ function sendNotification(options: Options | string): void { - if (typeof options === 'string') { + if (typeof options === "string") { // eslint-disable-next-line no-new - new window.Notification(options) + new window.Notification(options); } else { // eslint-disable-next-line no-new - new window.Notification(options.title, options) + new window.Notification(options.title, options); } } -export type { Options, Permission } +export type { Options, Permission }; -export { sendNotification, requestPermission, isPermissionGranted } +export { sendNotification, requestPermission, isPermissionGranted }; diff --git a/plugins/notification/src/init.js b/plugins/notification/src/init.js index 105a7f9b..a6597def 100644 --- a/plugins/notification/src/init.js +++ b/plugins/notification/src/init.js @@ -1,71 +1,71 @@ (function () { - let permissionSettable = false - let permissionValue = 'default' + let permissionSettable = false; + let permissionValue = "default"; function isPermissionGranted() { - if (window.Notification.permission !== 'default') { - return Promise.resolve(window.Notification.permission === 'granted') + if (window.Notification.permission !== "default") { + return Promise.resolve(window.Notification.permission === "granted"); } - return __TAURI__.invoke('plugin:notification|is_permission_granted') + return __TAURI__.invoke("plugin:notification|is_permission_granted"); } function setNotificationPermission(value) { - permissionSettable = true + permissionSettable = true; // @ts-expect-error we can actually set this value on the webview - window.Notification.permission = value - permissionSettable = false + window.Notification.permission = value; + permissionSettable = false; } function requestPermission() { - return __TAURI__.invoke('plugin:notification|request_permission') + return __TAURI__ + .invoke("plugin:notification|request_permission") .then(function (permission) { - setNotificationPermission(permission) - return permission - }) + setNotificationPermission(permission); + return permission; + }); } function sendNotification(options) { - if (typeof options === 'object') { - Object.freeze(options) + if (typeof options === "object") { + Object.freeze(options); } - return __TAURI__.invoke('plugin:notification|notify', { - options: typeof options === 'string' - ? { - title: options - } - : options - }) + return __TAURI__.invoke("plugin:notification|notify", { + options: + typeof options === "string" + ? { + title: options, + } + : options, + }); } // @ts-expect-error unfortunately we can't implement the whole type, so we overwrite it with our own version window.Notification = function (title, options) { - const opts = options || {} - sendNotification( - Object.assign(opts, { title }) - ) - } + const opts = options || {}; + sendNotification(Object.assign(opts, { title })); + }; - window.Notification.requestPermission = requestPermission + window.Notification.requestPermission = requestPermission; - Object.defineProperty(window.Notification, 'permission', { + Object.defineProperty(window.Notification, "permission", { enumerable: true, get: function () { - return permissionValue + return permissionValue; }, set: function (v) { if (!permissionSettable) { - throw new Error('Readonly property') + throw new Error("Readonly property"); } - permissionValue = v - } - }) + permissionValue = v; + }, + }); isPermissionGranted().then(function (response) { if (response === null) { - setNotificationPermission('default') + setNotificationPermission("default"); } else { - setNotificationPermission(response ? 'granted' : 'denied') + setNotificationPermission(response ? "granted" : "denied"); } - }) -})() + }); +})(); diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts index 8a9ac210..4787fb2c 100644 --- a/plugins/shell/guest-js/index.ts +++ b/plugins/shell/guest-js/index.ts @@ -75,27 +75,27 @@ * @module */ -import { invoke, transformCallback } from '@tauri-apps/api/tauri' +import { invoke, transformCallback } from "@tauri-apps/api/tauri"; /** * @since 1.0.0 */ interface SpawnOptions { /** Current working directory. */ - cwd?: string + cwd?: string; /** Environment variables. set to `null` to clear the process env. */ - env?: Record + env?: Record; /** * Character encoding for stdout/stderr * * @since 1.1.0 * */ - encoding?: string + encoding?: string; } /** @ignore */ interface InternalSpawnOptions extends SpawnOptions { - sidecar?: boolean + sidecar?: boolean; } /** @@ -103,13 +103,13 @@ interface InternalSpawnOptions extends SpawnOptions { */ interface ChildProcess { /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ - code: number | null + code: number | null; /** If the process was terminated by a signal, represents that signal. */ - signal: number | null + signal: number | null; /** The data that the process wrote to `stdout`. */ - stdout: O + stdout: O; /** The data that the process wrote to `stderr`. */ - stderr: O + stderr: O; } /** @@ -128,16 +128,16 @@ async function execute( args: string | string[] = [], options?: InternalSpawnOptions ): Promise { - if (typeof args === 'object') { - Object.freeze(args) + if (typeof args === "object") { + Object.freeze(args); } - return invoke('plugin:shell|execute', { + return invoke("plugin:shell|execute", { program, args, options, - onEventFn: transformCallback(onEvent) - }) + onEventFn: transformCallback(onEvent), + }); } /** @@ -147,7 +147,7 @@ class EventEmitter> { /** @ignore */ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment private eventListeners: Record void>> = - Object.create(null) + Object.create(null); /** * Alias for `emitter.on(eventName, listener)`. @@ -158,7 +158,7 @@ class EventEmitter> { eventName: N, listener: (arg: E[typeof eventName]) => void ): this { - return this.on(eventName, listener) + return this.on(eventName, listener); } /** @@ -170,7 +170,7 @@ class EventEmitter> { eventName: N, listener: (arg: E[typeof eventName]) => void ): this { - return this.off(eventName, listener) + return this.off(eventName, listener); } /** @@ -189,12 +189,12 @@ class EventEmitter> { ): this { if (eventName in this.eventListeners) { // eslint-disable-next-line security/detect-object-injection - this.eventListeners[eventName].push(listener) + this.eventListeners[eventName].push(listener); } else { // eslint-disable-next-line security/detect-object-injection - this.eventListeners[eventName] = [listener] + this.eventListeners[eventName] = [listener]; } - return this + return this; } /** @@ -210,11 +210,11 @@ class EventEmitter> { listener: (arg: E[typeof eventName]) => void ): this { const wrapper = (arg: E[typeof eventName]): void => { - this.removeListener(eventName, wrapper) + this.removeListener(eventName, wrapper); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - listener(arg) - } - return this.addListener(eventName, wrapper) + listener(arg); + }; + return this.addListener(eventName, wrapper); } /** @@ -231,9 +231,9 @@ class EventEmitter> { // eslint-disable-next-line security/detect-object-injection this.eventListeners[eventName] = this.eventListeners[eventName].filter( (l) => l !== listener - ) + ); } - return this + return this; } /** @@ -246,12 +246,12 @@ class EventEmitter> { removeAllListeners(event?: N): this { if (event) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete,security/detect-object-injection - delete this.eventListeners[event] + delete this.eventListeners[event]; } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - this.eventListeners = Object.create(null) + this.eventListeners = Object.create(null); } - return this + return this; } /** @@ -264,12 +264,12 @@ class EventEmitter> { emit(eventName: N, arg: E[typeof eventName]): boolean { if (eventName in this.eventListeners) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,security/detect-object-injection - const listeners = this.eventListeners[eventName] + const listeners = this.eventListeners[eventName]; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - for (const listener of listeners) listener(arg) - return true + for (const listener of listeners) listener(arg); + return true; } - return false + return false; } /** @@ -280,8 +280,8 @@ class EventEmitter> { listenerCount(eventName: N): number { if (eventName in this.eventListeners) // eslint-disable-next-line security/detect-object-injection - return this.eventListeners[eventName].length - return 0 + return this.eventListeners[eventName].length; + return 0; } /** @@ -300,12 +300,12 @@ class EventEmitter> { ): this { if (eventName in this.eventListeners) { // eslint-disable-next-line security/detect-object-injection - this.eventListeners[eventName].unshift(listener) + this.eventListeners[eventName].unshift(listener); } else { // eslint-disable-next-line security/detect-object-injection - this.eventListeners[eventName] = [listener] + this.eventListeners[eventName] = [listener]; } - return this + return this; } /** @@ -321,11 +321,11 @@ class EventEmitter> { listener: (arg: E[typeof eventName]) => void ): this { const wrapper = (arg: any): void => { - this.removeListener(eventName, wrapper) + this.removeListener(eventName, wrapper); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - listener(arg) - } - return this.prependListener(eventName, wrapper) + listener(arg); + }; + return this.prependListener(eventName, wrapper); } } @@ -334,10 +334,10 @@ class EventEmitter> { */ class Child { /** The child process `pid`. */ - pid: number + pid: number; constructor(pid: number) { - this.pid = pid + this.pid = pid; } /** @@ -356,11 +356,11 @@ class Child { * @returns A promise indicating the success or failure of the operation. */ async write(data: IOPayload): Promise { - return invoke('plugin:shell|stdin_write', { + return invoke("plugin:shell|stdin_write", { pid: this.pid, // correctly serialize Uint8Arrays - buffer: typeof data === 'string' ? data : Array.from(data) - }) + buffer: typeof data === "string" ? data : Array.from(data), + }); } /** @@ -369,20 +369,20 @@ class Child { * @returns A promise indicating the success or failure of the operation. */ async kill(): Promise { - return invoke('plugin:shell|kill', { - cmd: 'killChild', - pid: this.pid - }) + return invoke("plugin:shell|kill", { + cmd: "killChild", + pid: this.pid, + }); } } interface CommandEvents { - close: TerminatedPayload - error: string + close: TerminatedPayload; + error: string; } interface OutputEvents { - data: O + data: O; } /** @@ -408,15 +408,15 @@ interface OutputEvents { */ class Command extends EventEmitter { /** @ignore Program to execute. */ - private readonly program: string + private readonly program: string; /** @ignore Program arguments */ - private readonly args: string[] + private readonly args: string[]; /** @ignore Spawn options. */ - private readonly options: InternalSpawnOptions + private readonly options: InternalSpawnOptions; /** Event emitter for the `stdout`. Emits the `data` event. */ - readonly stdout = new EventEmitter>() + readonly stdout = new EventEmitter>(); /** Event emitter for the `stderr`. Emits the `data` event. */ - readonly stderr = new EventEmitter>() + readonly stderr = new EventEmitter>(); /** * @ignore @@ -432,23 +432,23 @@ class Command extends EventEmitter { args: string | string[] = [], options?: SpawnOptions ) { - super() - this.program = program - this.args = typeof args === 'string' ? [args] : args - this.options = options ?? {} + super(); + this.program = program; + this.args = typeof args === "string" ? [args] : args; + this.options = options ?? {}; } - static create(program: string, args?: string | string[]): Command + static create(program: string, args?: string | string[]): Command; static create( program: string, args?: string | string[], - options?: SpawnOptions & { encoding: 'raw' } - ): Command + options?: SpawnOptions & { encoding: "raw" } + ): Command; static create( program: string, args?: string | string[], options?: SpawnOptions - ): Command + ): Command; /** * Creates a command to execute the given program. @@ -467,20 +467,20 @@ class Command extends EventEmitter { args: string | string[] = [], options?: SpawnOptions ): Command { - return new Command(program, args, options) + return new Command(program, args, options); } - static sidecar(program: string, args?: string | string[]): Command + static sidecar(program: string, args?: string | string[]): Command; static sidecar( program: string, args?: string | string[], - options?: SpawnOptions & { encoding: 'raw' } - ): Command + options?: SpawnOptions & { encoding: "raw" } + ): Command; static sidecar( program: string, args?: string | string[], options?: SpawnOptions - ): Command + ): Command; /** * Creates a command to execute the given sidecar program. @@ -499,9 +499,9 @@ class Command extends EventEmitter { args: string | string[] = [], options?: SpawnOptions ): Command { - const instance = new Command(program, args, options) - instance.options.sidecar = true - return instance + const instance = new Command(program, args, options); + instance.options.sidecar = true; + return instance; } /** @@ -513,24 +513,24 @@ class Command extends EventEmitter { return execute( (event) => { switch (event.event) { - case 'Error': - this.emit('error', event.payload) - break - case 'Terminated': - this.emit('close', event.payload) - break - case 'Stdout': - this.stdout.emit('data', event.payload) - break - case 'Stderr': - this.stderr.emit('data', event.payload) - break + case "Error": + this.emit("error", event.payload); + break; + case "Terminated": + this.emit("close", event.payload); + break; + case "Stdout": + this.stdout.emit("data", event.payload); + break; + case "Stderr": + this.stderr.emit("data", event.payload); + break; } }, this.program, this.args, this.options - ).then((pid) => new Child(pid)) + ).then((pid) => new Child(pid)); } /** @@ -549,38 +549,38 @@ class Command extends EventEmitter { */ async execute(): Promise> { return new Promise((resolve, reject) => { - this.on('error', reject) - - const stdout: O[] = [] - const stderr: O[] = [] - this.stdout.on('data', (line: O) => { - stdout.push(line) - }) - this.stderr.on('data', (line: O) => { - stderr.push(line) - }) - - this.on('close', (payload: TerminatedPayload) => { + this.on("error", reject); + + const stdout: O[] = []; + const stderr: O[] = []; + this.stdout.on("data", (line: O) => { + stdout.push(line); + }); + this.stderr.on("data", (line: O) => { + stderr.push(line); + }); + + this.on("close", (payload: TerminatedPayload) => { resolve({ code: payload.code, signal: payload.signal, stdout: this.collectOutput(stdout) as O, - stderr: this.collectOutput(stderr) as O - }) - }) + stderr: this.collectOutput(stderr) as O, + }); + }); - this.spawn().catch(reject) - }) + this.spawn().catch(reject); + }); } /** @ignore */ private collectOutput(events: O[]): string | Uint8Array { - if (this.options.encoding === 'raw') { + if (this.options.encoding === "raw") { return events.reduce((p, c) => { - return new Uint8Array([...p, ...(c as Uint8Array), 10]) - }, new Uint8Array()) + return new Uint8Array([...p, ...(c as Uint8Array), 10]); + }, new Uint8Array()); } else { - return events.join('\n') + return events.join("\n"); } } } @@ -589,8 +589,8 @@ class Command extends EventEmitter { * Describes the event message received from the command. */ interface Event { - event: T - payload: V + event: T; + payload: V; } /** @@ -598,20 +598,20 @@ interface Event { */ interface TerminatedPayload { /** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ - code: number | null + code: number | null; /** If the process was terminated by a signal, represents that signal. */ - signal: number | null + signal: number | null; } /** Event payload type */ -type IOPayload = string | Uint8Array +type IOPayload = string | Uint8Array; /** Events emitted by the child process. */ type CommandEvent = - | Event<'Stdout', O> - | Event<'Stderr', O> - | Event<'Terminated', TerminatedPayload> - | Event<'Error', string> + | Event<"Stdout", O> + | Event<"Stderr", O> + | Event<"Terminated", TerminatedPayload> + | Event<"Error", string>; /** * Opens a path or URL with the system's default app, @@ -640,18 +640,18 @@ type CommandEvent = * @since 1.0.0 */ async function open(path: string, openWith?: string): Promise { - return invoke('plugin:shell|open', { + return invoke("plugin:shell|open", { path, - with: openWith - }) + with: openWith, + }); } -export { Command, Child, EventEmitter, open } +export { Command, Child, EventEmitter, open }; export type { IOPayload, CommandEvents, TerminatedPayload, OutputEvents, ChildProcess, - SpawnOptions -} + SpawnOptions, +}; diff --git a/plugins/shell/package.json b/plugins/shell/package.json index aa0f9f4b..b6025864 100644 --- a/plugins/shell/package.json +++ b/plugins/shell/package.json @@ -29,4 +29,4 @@ "dependencies": { "@tauri-apps/api": "^1.2.0" } -} \ No newline at end of file +} diff --git a/tsconfig.base.json b/tsconfig.base.json index f40996b1..629a7c96 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,20 +1,20 @@ { - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "lib": ["ES2019", "ES2020.Promise", "ES2020.String", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "node", - "noEmit": true, - "noEmitOnError": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "pretty": true, - "sourceMap": true, - "strict": true, - "target": "ES2019", - "declaration": true, - "declarationDir": "./" - }, - "exclude": ["dist-js", "node_modules", "test/types"] + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["ES2019", "ES2020.Promise", "ES2020.String", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "node", + "noEmit": true, + "noEmitOnError": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "pretty": true, + "sourceMap": true, + "strict": true, + "target": "ES2019", + "declaration": true, + "declarationDir": "./" + }, + "exclude": ["dist-js", "node_modules", "test/types"] } From 507cf43e8286c349d0a210aab3529113bec8970b Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 09:32:30 +0200 Subject: [PATCH 11/14] chore: fix lint issues --- .eslintignore | 3 ++- .eslintrc.json | 3 ++- package.json | 1 + plugins/log/guest-js/index.ts | 3 ++- pnpm-lock.yaml | 20 ++++++++++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.eslintignore b/.eslintignore index 2c6d35b6..1c120ff5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ target node_modules -dist \ No newline at end of file +dist +dist-js diff --git a/.eslintrc.json b/.eslintrc.json index 4cc47de1..96b58264 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,7 +6,8 @@ "extends": [ "prettier", "eslint:recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended", + "plugin:security/recommended" ], "overrides": [], "parser": "@typescript-eslint/parser", diff --git a/package.json b/package.json index 30c223ed..5a0bfce4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-n": "^15.7.0", "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-security": "^1.7.1", "prettier": "^2.8.7", "rollup": "^3.20.4", "typescript": "^5.0.4" diff --git a/plugins/log/guest-js/index.ts b/plugins/log/guest-js/index.ts index f421b7bc..4fb4b0c9 100644 --- a/plugins/log/guest-js/index.ts +++ b/plugins/log/guest-js/index.ts @@ -189,7 +189,8 @@ export async function attachConsole(): Promise { // Strip ANSI escape codes const message = payload.message.replace( - // eslint-disable-next-line no-control-regex + // TODO: Investigate security/detect-unsafe-regex + // eslint-disable-next-line no-control-regex, security/detect-unsafe-regex /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "" ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4cdc08a..50c1bf1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: eslint-plugin-promise: specifier: ^6.1.1 version: 6.1.1(eslint@8.38.0) + eslint-plugin-security: + specifier: ^1.7.1 + version: 1.7.1 prettier: specifier: ^2.8.7 version: 2.8.7 @@ -1449,6 +1452,12 @@ packages: eslint: 8.38.0 dev: true + /eslint-plugin-security@1.7.1: + resolution: {integrity: sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==} + dependencies: + safe-regex: 2.1.1 + dev: true + /eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -2293,6 +2302,11 @@ packages: picomatch: 2.3.1 dev: true + /regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + dev: true + /regexp.prototype.flags@1.4.3: resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} engines: {node: '>= 0.4'} @@ -2373,6 +2387,12 @@ packages: is-regex: 1.1.4 dev: true + /safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + dependencies: + regexp-tree: 0.1.27 + dev: true + /sander@0.5.1: resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} dependencies: From 33d63525509d36be249234c0c3dfaee42e61409f Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 09:37:48 +0200 Subject: [PATCH 12/14] chore: Fix lint issues --- .../examples/tauri-app/{vite.config.js => vite.config.ts} | 0 plugins/notification/src/init.js | 6 +++--- plugins/shell/guest-js/index.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) rename plugins/dialog/examples/tauri-app/{vite.config.js => vite.config.ts} (100%) diff --git a/plugins/dialog/examples/tauri-app/vite.config.js b/plugins/dialog/examples/tauri-app/vite.config.ts similarity index 100% rename from plugins/dialog/examples/tauri-app/vite.config.js rename to plugins/dialog/examples/tauri-app/vite.config.ts diff --git a/plugins/notification/src/init.js b/plugins/notification/src/init.js index a6597def..d69acf61 100644 --- a/plugins/notification/src/init.js +++ b/plugins/notification/src/init.js @@ -6,7 +6,7 @@ if (window.Notification.permission !== "default") { return Promise.resolve(window.Notification.permission === "granted"); } - return __TAURI__.invoke("plugin:notification|is_permission_granted"); + return window.__TAURI__.invoke("plugin:notification|is_permission_granted"); } function setNotificationPermission(value) { @@ -17,7 +17,7 @@ } function requestPermission() { - return __TAURI__ + return window.__TAURI__ .invoke("plugin:notification|request_permission") .then(function (permission) { setNotificationPermission(permission); @@ -30,7 +30,7 @@ Object.freeze(options); } - return __TAURI__.invoke("plugin:notification|notify", { + return window.__TAURI__.invoke("plugin:notification|notify", { options: typeof options === "string" ? { diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts index 4787fb2c..0cf86b0b 100644 --- a/plugins/shell/guest-js/index.ts +++ b/plugins/shell/guest-js/index.ts @@ -143,9 +143,10 @@ async function execute( /** * @since 1.0.0 */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any class EventEmitter> { /** @ignore */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any private eventListeners: Record void>> = Object.create(null); @@ -320,6 +321,7 @@ class EventEmitter> { eventName: N, listener: (arg: E[typeof eventName]) => void ): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const wrapper = (arg: any): void => { this.removeListener(eventName, wrapper); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument From 1397172e95aba7b25f3c773930216e2fe0254261 Mon Sep 17 00:00:00 2001 From: FabianLars Date: Wed, 3 May 2023 09:51:24 +0200 Subject: [PATCH 13/14] chore: Fix shell tests --- plugins/shell/src/process/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/shell/src/process/mod.rs b/plugins/shell/src/process/mod.rs index 751d54d4..1b3befd7 100644 --- a/plugins/shell/src/process/mod.rs +++ b/plugins/shell/src/process/mod.rs @@ -480,7 +480,7 @@ mod tests { #[test] fn test_cmd_output_output() { let cmd = Command::new("cat").args(["test/api/test.txt"]); - let output = cmd.output().unwrap(); + let output = tauri::async_runtime::block_on(cmd.output()).unwrap(); assert_eq!(String::from_utf8(output.stderr).unwrap(), ""); assert_eq!( @@ -493,7 +493,7 @@ mod tests { #[test] fn test_cmd_output_output_fail() { let cmd = Command::new("cat").args(["test/api/"]); - let output = cmd.output().unwrap(); + let output = tauri::async_runtime::block_on(cmd.output()).unwrap(); assert_eq!(String::from_utf8(output.stdout).unwrap(), ""); assert_eq!( From be1c775b8d4f6ac2ee3c71edd49497d02ffdfe4f Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Fri, 5 May 2023 05:22:19 -0700 Subject: [PATCH 14/14] feat(notification): implement Android and iOS APIs (#340) --- Cargo.lock | 4 + plugins/notification/Cargo.toml | 4 + plugins/notification/android/build.gradle.kts | 4 +- .../android/src/main/AndroidManifest.xml | 21 +- .../android/src/main/java/AssetUtils.kt | 25 + .../android/src/main/java/ChannelManager.kt | 150 +++++ .../android/src/main/java/Notification.kt | 165 +++++ .../src/main/java/NotificationAction.kt | 47 ++ .../src/main/java/NotificationAttachment.kt | 48 ++ .../src/main/java/NotificationPlugin.kt | 264 +++++++- .../src/main/java/NotificationSchedule.kt | 305 ++++++++++ .../src/main/java/NotificationStorage.kt | 131 ++++ .../src/main/java/TauriNotificationManager.kt | 569 ++++++++++++++++++ .../src/main/res/drawable/ic_transparent.xml | 12 + plugins/notification/guest-js/index.ts | 510 +++++++++++++++- plugins/notification/ios/Package.swift | 8 +- .../ios/Sources/Notification.swift | 272 +++++++++ .../ios/Sources/NotificationCategory.swift | 131 ++++ .../ios/Sources/NotificationHandler.swift | 116 ++++ .../ios/Sources/NotificationManager.swift | 39 ++ .../ios/Sources/NotificationPlugin.swift | 217 ++++++- plugins/notification/src/commands.rs | 37 +- plugins/notification/src/lib.rs | 137 ++++- plugins/notification/src/mobile.rs | 113 +++- plugins/notification/src/models.rs | 463 +++++++++++++- 25 files changed, 3701 insertions(+), 91 deletions(-) create mode 100644 plugins/notification/android/src/main/java/AssetUtils.kt create mode 100644 plugins/notification/android/src/main/java/ChannelManager.kt create mode 100644 plugins/notification/android/src/main/java/Notification.kt create mode 100644 plugins/notification/android/src/main/java/NotificationAction.kt create mode 100644 plugins/notification/android/src/main/java/NotificationAttachment.kt create mode 100644 plugins/notification/android/src/main/java/NotificationSchedule.kt create mode 100644 plugins/notification/android/src/main/java/NotificationStorage.kt create mode 100644 plugins/notification/android/src/main/java/TauriNotificationManager.kt create mode 100644 plugins/notification/android/src/main/res/drawable/ic_transparent.xml create mode 100644 plugins/notification/ios/Sources/Notification.swift create mode 100644 plugins/notification/ios/Sources/NotificationCategory.swift create mode 100644 plugins/notification/ios/Sources/NotificationHandler.swift create mode 100644 plugins/notification/ios/Sources/NotificationManager.swift diff --git a/Cargo.lock b/Cargo.lock index 365ada67..4624b32c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4927,11 +4927,15 @@ version = "0.1.0" dependencies = [ "log", "notify-rust", + "rand 0.8.5", "serde", "serde_json", + "serde_repr", "tauri", "tauri-build", "thiserror", + "time 0.3.20", + "url", "win7-notifications", ] diff --git a/plugins/notification/Cargo.toml b/plugins/notification/Cargo.toml index 57d5c149..f136b2e5 100644 --- a/plugins/notification/Cargo.toml +++ b/plugins/notification/Cargo.toml @@ -16,6 +16,10 @@ serde_json.workspace = true tauri.workspace = true log.workspace = true thiserror.workspace = true +rand = "0.8" +time = { version = "0.3", features = ["serde", "parsing", "formatting"] } +url = { version = "2", features = ["serde"] } +serde_repr = "0.1" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] notify-rust = "4.5" diff --git a/plugins/notification/android/build.gradle.kts b/plugins/notification/android/build.gradle.kts index 5fdedcf4..7d961104 100644 --- a/plugins/notification/android/build.gradle.kts +++ b/plugins/notification/android/build.gradle.kts @@ -5,11 +5,11 @@ plugins { android { namespace = "app.tauri.notification" - compileSdk = 32 + compileSdk = 33 defaultConfig { minSdk = 24 - targetSdk = 32 + targetSdk = 33 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") diff --git a/plugins/notification/android/src/main/AndroidManifest.xml b/plugins/notification/android/src/main/AndroidManifest.xml index 9a40236b..986d5f85 100644 --- a/plugins/notification/android/src/main/AndroidManifest.xml +++ b/plugins/notification/android/src/main/AndroidManifest.xml @@ -1,3 +1,20 @@ - - + + + + + + + + + + + + + + + diff --git a/plugins/notification/android/src/main/java/AssetUtils.kt b/plugins/notification/android/src/main/java/AssetUtils.kt new file mode 100644 index 00000000..c97cd528 --- /dev/null +++ b/plugins/notification/android/src/main/java/AssetUtils.kt @@ -0,0 +1,25 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.content.Context + +class AssetUtils { + companion object { + const val RESOURCE_ID_ZERO_VALUE = 0 + + @SuppressLint("DiscouragedApi") + fun getResourceID(context: Context, resourceName: String?, dir: String?): Int { + return context.resources.getIdentifier(resourceName, dir, context.packageName) + } + + fun getResourceBaseName(resPath: String?): String? { + if (resPath == null) return null + if (resPath.contains("/")) { + return resPath.substring(resPath.lastIndexOf('/') + 1) + } + return if (resPath.contains(".")) { + resPath.substring(0, resPath.lastIndexOf('.')) + } else resPath + } + } +} diff --git a/plugins/notification/android/src/main/java/ChannelManager.kt b/plugins/notification/android/src/main/java/ChannelManager.kt new file mode 100644 index 00000000..cf68e666 --- /dev/null +++ b/plugins/notification/android/src/main/java/ChannelManager.kt @@ -0,0 +1,150 @@ +package app.tauri.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ContentResolver +import android.content.Context +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import app.tauri.Logger +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject + +private const val CHANNEL_ID = "id" +private const val CHANNEL_NAME = "name" +private const val CHANNEL_DESCRIPTION = "description" +private const val CHANNEL_IMPORTANCE = "importance" +private const val CHANNEL_VISIBILITY = "visibility" +private const val CHANNEL_SOUND = "sound" +private const val CHANNEL_VIBRATE = "vibration" +private const val CHANNEL_USE_LIGHTS = "lights" +private const val CHANNEL_LIGHT_COLOR = "lightColor" + +class ChannelManager(private var context: Context) { + private var notificationManager: NotificationManager? = null + + init { + notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager? + } + + fun createChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = JSObject() + if (invoke.getString(CHANNEL_ID) != null) { + channel.put(CHANNEL_ID, invoke.getString(CHANNEL_ID)) + } else { + invoke.reject("Channel missing identifier") + return + } + if (invoke.getString(CHANNEL_NAME) != null) { + channel.put(CHANNEL_NAME, invoke.getString(CHANNEL_NAME)) + } else { + invoke.reject("Channel missing name") + return + } + channel.put( + CHANNEL_IMPORTANCE, + invoke.getInt(CHANNEL_IMPORTANCE, NotificationManager.IMPORTANCE_DEFAULT) + ) + channel.put(CHANNEL_DESCRIPTION, invoke.getString(CHANNEL_DESCRIPTION, "")) + channel.put( + CHANNEL_VISIBILITY, + invoke.getInt(CHANNEL_VISIBILITY, NotificationCompat.VISIBILITY_PUBLIC) + ) + channel.put(CHANNEL_SOUND, invoke.getString(CHANNEL_SOUND)) + channel.put(CHANNEL_VIBRATE, invoke.getBoolean(CHANNEL_VIBRATE, false)) + channel.put(CHANNEL_USE_LIGHTS, invoke.getBoolean(CHANNEL_USE_LIGHTS, false)) + channel.put(CHANNEL_LIGHT_COLOR, invoke.getString(CHANNEL_LIGHT_COLOR)) + createChannel(channel) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + private fun createChannel(channel: JSObject) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannel = NotificationChannel( + channel.getString(CHANNEL_ID), + channel.getString(CHANNEL_NAME), + channel.getInteger(CHANNEL_IMPORTANCE)!! + ) + notificationChannel.description = channel.getString(CHANNEL_DESCRIPTION) + notificationChannel.lockscreenVisibility = channel.getInteger(CHANNEL_VISIBILITY, android.app.Notification.VISIBILITY_PRIVATE) + notificationChannel.enableVibration(channel.getBoolean(CHANNEL_VIBRATE, false)) + notificationChannel.enableLights(channel.getBoolean(CHANNEL_USE_LIGHTS, false)) + val lightColor = channel.getString(CHANNEL_LIGHT_COLOR) + if (lightColor.isNotEmpty()) { + try { + notificationChannel.lightColor = Color.parseColor(lightColor) + } catch (ex: IllegalArgumentException) { + Logger.error( + Logger.tags("NotificationChannel"), + "Invalid color provided for light color.", + null + ) + } + } + var sound = channel.getString(CHANNEL_SOUND) + if (sound.isNotEmpty()) { + if (sound.contains(".")) { + sound = sound.substring(0, sound.lastIndexOf('.')) + } + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build() + val soundUri = + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/raw/" + sound) + notificationChannel.setSound(soundUri, audioAttributes) + } + notificationManager?.createNotificationChannel(notificationChannel) + } + } + + fun deleteChannel(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = invoke.getString("id") + notificationManager?.deleteNotificationChannel(channelId) + invoke.resolve() + } else { + invoke.reject("channel not available") + } + } + + fun listChannels(invoke: Invoke) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationChannels: List = + notificationManager?.notificationChannels ?: listOf() + val channels = JSArray() + for (notificationChannel in notificationChannels) { + val channel = JSObject() + channel.put(CHANNEL_ID, notificationChannel.id) + channel.put(CHANNEL_NAME, notificationChannel.name) + channel.put(CHANNEL_DESCRIPTION, notificationChannel.description) + channel.put(CHANNEL_IMPORTANCE, notificationChannel.importance) + channel.put(CHANNEL_VISIBILITY, notificationChannel.lockscreenVisibility) + channel.put(CHANNEL_SOUND, notificationChannel.sound) + channel.put(CHANNEL_VIBRATE, notificationChannel.shouldVibrate()) + channel.put(CHANNEL_USE_LIGHTS, notificationChannel.shouldShowLights()) + channel.put( + CHANNEL_LIGHT_COLOR, String.format( + "#%06X", + 0xFFFFFF and notificationChannel.lightColor + ) + ) + channels.put(channel) + } + val result = JSObject() + result.put("channels", channels) + invoke.resolve(result) + } else { + invoke.reject("channel not available") + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/Notification.kt b/plugins/notification/android/src/main/java/Notification.kt new file mode 100644 index 00000000..3839807b --- /dev/null +++ b/plugins/notification/android/src/main/java/Notification.kt @@ -0,0 +1,165 @@ +package app.tauri.notification + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONException +import org.json.JSONObject + +class Notification { + var title: String? = null + var body: String? = null + var largeBody: String? = null + var summary: String? = null + var id: Int = 0 + private var sound: String? = null + private var smallIcon: String? = null + private var largeIcon: String? = null + var iconColor: String? = null + var actionTypeId: String? = null + var group: String? = null + var inboxLines: List? = null + var isGroupSummary = false + var isOngoing = false + var isAutoCancel = false + var extra: JSObject? = null + var attachments: List? = null + var schedule: NotificationSchedule? = null + var channelId: String? = null + var source: JSObject? = null + var visibility: Int? = null + var number: Int? = null + + fun getSound(context: Context, defaultSound: Int): String? { + var soundPath: String? = null + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val name = AssetUtils.getResourceBaseName(sound) + if (name != null) { + resId = AssetUtils.getResourceID(context, name, "raw") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultSound + } + if (resId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + soundPath = + ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + resId + } + return soundPath + } + + fun setSound(sound: String?) { + this.sound = sound + } + + fun setSmallIcon(smallIcon: String?) { + this.smallIcon = AssetUtils.getResourceBaseName(smallIcon) + } + + fun setLargeIcon(largeIcon: String?) { + this.largeIcon = AssetUtils.getResourceBaseName(largeIcon) + } + + fun getIconColor(globalColor: String): String { + // use the one defined local before trying for a globally defined color + return iconColor ?: globalColor + } + + fun getSmallIcon(context: Context, defaultIcon: Int): Int { + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + if (smallIcon != null) { + resId = AssetUtils.getResourceID(context, smallIcon, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = defaultIcon + } + return resId + } + + fun getLargeIcon(context: Context): Bitmap? { + if (largeIcon != null) { + val resId: Int = AssetUtils.getResourceID(context, largeIcon, "drawable") + return BitmapFactory.decodeResource(context.resources, resId) + } + return null + } + + val isScheduled = schedule != null + + companion object { + fun fromJson(jsonNotification: JSONObject): Notification { + val notification: JSObject = try { + val identifier = jsonNotification.getLong("id") + if (identifier > Int.MAX_VALUE || identifier < Int.MIN_VALUE) { + throw Exception("The notification identifier should be a 32-bit integer") + } + JSObject.fromJSONObject(jsonNotification) + } catch (e: JSONException) { + throw Exception("Invalid notification JSON object", e) + } + return fromJSObject(notification) + } + + fun fromJSObject(jsonObject: JSObject): Notification { + val notification = Notification() + notification.source = jsonObject + notification.id = jsonObject.getInteger("id") ?: throw Exception("Missing notification identifier") + notification.body = jsonObject.getString("body", null) + notification.largeBody = jsonObject.getString("largeBody", null) + notification.summary = jsonObject.getString("summary", null) + notification.actionTypeId = jsonObject.getString("actionTypeId", null) + notification.group = jsonObject.getString("group", null) + notification.setSound(jsonObject.getString("sound", null)) + notification.title = jsonObject.getString("title", null) + notification.setSmallIcon(jsonObject.getString("icon", null)) + notification.setLargeIcon(jsonObject.getString("largeIcon", null)) + notification.iconColor = jsonObject.getString("iconColor", null) + notification.attachments = NotificationAttachment.getAttachments(jsonObject) + notification.isGroupSummary = jsonObject.getBoolean("groupSummary", false) + notification.channelId = jsonObject.getString("channelId", null) + val schedule = jsonObject.getJSObject("schedule") + if (schedule != null) { + notification.schedule = NotificationSchedule(schedule) + } + notification.extra = jsonObject.getJSObject("extra") + notification.isOngoing = jsonObject.getBoolean("ongoing", false) + notification.isAutoCancel = jsonObject.getBoolean("autoCancel", true) + notification.visibility = jsonObject.getInteger("visibility") + notification.number = jsonObject.getInteger("number") + try { + val inboxLines = jsonObject.getJSONArray("inboxLines") + val inboxStringList: MutableList = ArrayList() + for (i in 0 until inboxLines.length()) { + inboxStringList.add(inboxLines.getString(i)) + } + notification.inboxLines = inboxStringList + } catch (_: Exception) { + } + return notification + } + + fun buildNotificationPendingList(notifications: List): JSObject { + val result = JSObject() + val jsArray = JSArray() + for (notification in notifications) { + val jsNotification = JSObject() + jsNotification.put("id", notification.id) + jsNotification.put("title", notification.title) + jsNotification.put("body", notification.body) + val schedule = notification.schedule + if (schedule != null) { + val jsSchedule = JSObject() + jsSchedule.put("kind", schedule.scheduleObj.getString("kind", null)) + jsSchedule.put("data", schedule.scheduleObj.getJSObject("data")) + jsNotification.put("schedule", jsSchedule) + } + jsNotification.put("extra", notification.extra) + jsArray.put(jsNotification) + } + result.put("notifications", jsArray) + return result + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationAction.kt b/plugins/notification/android/src/main/java/NotificationAction.kt new file mode 100644 index 00000000..c1a964b4 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationAction.kt @@ -0,0 +1,47 @@ +package app.tauri.notification + +import app.tauri.Logger +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import org.json.JSONObject + +class NotificationAction() { + var id: String? = null + var title: String? = null + var input = false + + constructor(id: String?, title: String?, input: Boolean): this() { + this.id = id + this.title = title + this.input = input + } + + companion object { + fun buildTypes(types: JSArray): Map> { + val actionTypeMap: MutableMap> = HashMap() + try { + val objects: List = types.toList() + for (obj in objects) { + val jsObject = JSObject.fromJSONObject( + obj + ) + val actionGroupId = jsObject.getString("id") + val actions = jsObject.getJSONArray("actions") + val typesArray = mutableListOf() + for (i in 0 until actions.length()) { + val notificationAction = NotificationAction() + val action = JSObject.fromJSONObject(actions.getJSONObject(i)) + notificationAction.id = action.getString("id") + notificationAction.title = action.getString("title") + notificationAction.input = action.getBoolean("input") + typesArray.add(notificationAction) + } + actionTypeMap[actionGroupId] = typesArray.toList() + } + } catch (e: Exception) { + Logger.error(Logger.tags("Notification"), "Error when building action types", e) + } + return actionTypeMap + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationAttachment.kt b/plugins/notification/android/src/main/java/NotificationAttachment.kt new file mode 100644 index 00000000..1cc35e89 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationAttachment.kt @@ -0,0 +1,48 @@ +package app.tauri.notification + +import app.tauri.plugin.JSObject +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class NotificationAttachment { + var id: String? = null + var url: String? = null + var options: JSONObject? = null + + companion object { + fun getAttachments(notification: JSObject): List { + val attachmentsList: MutableList = ArrayList() + var attachments: JSONArray? = null + try { + attachments = notification.getJSONArray("attachments") + } catch (_: Exception) { + } + if (attachments != null) { + for (i in 0 until attachments.length()) { + val newAttachment = NotificationAttachment() + var jsonObject: JSONObject? = null + try { + jsonObject = attachments.getJSONObject(i) + } catch (e: JSONException) { + } + if (jsonObject != null) { + var jsObject: JSObject? = null + try { + jsObject = JSObject.fromJSONObject(jsonObject) + } catch (_: JSONException) { + } + newAttachment.id = jsObject!!.getString("id") + newAttachment.url = jsObject.getString("url") + try { + newAttachment.options = jsObject.getJSONObject("options") + } catch (_: JSONException) { + } + attachmentsList.add(newAttachment) + } + } + } + return attachmentsList + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationPlugin.kt b/plugins/notification/android/src/main/java/NotificationPlugin.kt index ab6c9df7..f87bcf17 100644 --- a/plugins/notification/android/src/main/java/NotificationPlugin.kt +++ b/plugins/notification/android/src/main/java/NotificationPlugin.kt @@ -1,31 +1,263 @@ package app.tauri.notification +import android.Manifest +import android.annotation.SuppressLint import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.webkit.WebView +import app.tauri.PermissionState import app.tauri.annotation.Command +import app.tauri.annotation.Permission +import app.tauri.annotation.PermissionCallback import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin -import app.tauri.plugin.Invoke +import org.json.JSONException +import org.json.JSONObject + +const val LOCAL_NOTIFICATIONS = "permissionState" -@TauriPlugin +@TauriPlugin( + permissions = [ + Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState") + ] +) class NotificationPlugin(private val activity: Activity): Plugin(activity) { - @Command - fun requestPermission(invoke: Invoke) { - val ret = JSObject() - ret.put("permissionState", "granted") - invoke.resolve(ret) + private var webView: WebView? = null + private lateinit var manager: TauriNotificationManager + private lateinit var notificationManager: NotificationManager + private lateinit var notificationStorage: NotificationStorage + private var channelManager = ChannelManager(activity) + + companion object { + var instance: NotificationPlugin? = null + + fun triggerNotification(notification: JSObject) { + instance?.trigger("notification", notification) + } + } + + override fun load(webView: WebView) { + instance = this + + super.load(webView) + this.webView = webView + notificationStorage = NotificationStorage(activity) + + val manager = TauriNotificationManager( + notificationStorage, + activity, + activity, + getConfig() + ) + manager.createNotificationChannel() + + this.manager = manager + + notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (Intent.ACTION_MAIN != intent.action) { + return + } + val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) + if (dataJson != null) { + trigger("actionPerformed", dataJson) + } + } + + @Command + fun show(invoke: Invoke) { + val notification = Notification.fromJSObject(invoke.data) + val id = manager.schedule(notification) + + val returnVal = JSObject().put("id", id) + invoke.resolve(returnVal) + } + + @Command + fun batch(invoke: Invoke) { + val notificationArray = invoke.getArray("notifications") + if (notificationArray == null) { + invoke.reject("Missing `notifications` argument") + return + } + + val notifications: MutableList = + ArrayList(notificationArray.length()) + val notificationsInput: List = try { + notificationArray.toList() + } catch (e: JSONException) { + invoke.reject("Provided notification format is invalid") + return + } + + for (jsonNotification in notificationsInput) { + val notification = Notification.fromJson(jsonNotification) + notifications.add(notification) } - @Command - fun permissionState(invoke: Invoke) { - val ret = JSObject() - ret.put("permissionState", "granted") - invoke.resolve(ret) + val ids = manager.schedule(notifications) + notificationStorage.appendNotifications(notifications) + + val result = JSObject() + result.put("notifications", ids) + invoke.resolve(result) + } + + @Command + fun cancel(invoke: Invoke) { + val notifications: List = invoke.getArray("notifications", JSArray()).toList() + if (notifications.isEmpty()) { + invoke.reject("Must provide notifications array as notifications option") + return + } + + manager.cancel(notifications) + invoke.resolve() + } + + @Command + fun removeActive(invoke: Invoke) { + val notifications = invoke.getArray("notifications") + if (notifications == null) { + notificationManager.cancelAll() + invoke.resolve() + } else { + try { + for (o in notifications.toList()) { + if (o is JSONObject) { + val notification = JSObject.fromJSONObject((o)) + val tag = notification.getString("tag", null) + val id = notification.getInteger("id", 0) + if (tag == null) { + notificationManager.cancel(id) + } else { + notificationManager.cancel(tag, id) + } + } else { + invoke.reject("Unexpected notification type") + return + } + } + } catch (e: JSONException) { + invoke.reject(e.message) + } + invoke.resolve() + } + } + + @Command + fun getPending(invoke: Invoke) { + val notifications= notificationStorage.getSavedNotifications() + val result = Notification.buildNotificationPendingList(notifications) + invoke.resolve(result) + } + + @Command + fun registerActionTypes(invoke: Invoke) { + val types = invoke.getArray("types", JSArray()) + val typesArray = NotificationAction.buildTypes(types) + notificationStorage.writeActionGroup(typesArray) + invoke.resolve() + } + + @SuppressLint("ObsoleteSdkInt") + @Command + fun getActive(invoke: Invoke) { + val notifications = JSArray() + 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 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", + 0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY + ) + val extras = JSObject() + for (key in notification.extras.keySet()) { + extras.put(key!!, notification.extras.getString(key)) + } + jsNotification.put("data", extras) + } + notifications.put(jsNotification) + } } + val result = JSObject() + result.put("notifications", notifications) + invoke.resolve(result) + } + + @Command + fun createChannel(invoke: Invoke) { + channelManager.createChannel(invoke) + } + + @Command + fun deleteChannel(invoke: Invoke) { + channelManager.deleteChannel(invoke) + } + + @Command + fun listChannels(invoke: Invoke) { + channelManager.listChannels(invoke) + } + + @Command + override fun checkPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } else { + super.checkPermissions(invoke) + } + } + + @Command + override fun requestPermissions(invoke: Invoke) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + permissionState(invoke) + } else { + if (getPermissionState(LOCAL_NOTIFICATIONS) !== PermissionState.GRANTED) { + requestPermissionForAlias(LOCAL_NOTIFICATIONS, invoke, "permissionsCallback") + } + } + } + + @Command + fun permissionState(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("permissionState", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } + + @PermissionCallback + private fun permissionsCallback(invoke: Invoke) { + val permissionsResultJSON = JSObject() + permissionsResultJSON.put("display", getPermissionState()) + invoke.resolve(permissionsResultJSON) + } - @Command - fun notify(invoke: Invoke) { - // TODO - invoke.resolve() + private fun getPermissionState(): String { + return if (manager.areNotificationsEnabled()) { + "granted" + } else { + "denied" } + } } diff --git a/plugins/notification/android/src/main/java/NotificationSchedule.kt b/plugins/notification/android/src/main/java/NotificationSchedule.kt new file mode 100644 index 00000000..89edbc9d --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationSchedule.kt @@ -0,0 +1,305 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.text.format.DateUtils +import app.tauri.plugin.JSObject +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.TimeZone + +const val JS_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + +enum class NotificationInterval { + Year, Month, TwoWeeks, Week, Day, Hour, Minute, Second +} + +fun getIntervalTime(interval: NotificationInterval, count: Int): Long { + return when (interval) { + // This case is just approximation as not all years have the same number of days + NotificationInterval.Year -> count * DateUtils.WEEK_IN_MILLIS * 52 + // This case is just approximation as months have different number of days + NotificationInterval.Month -> count * 30 * DateUtils.DAY_IN_MILLIS + NotificationInterval.TwoWeeks -> count * 2 * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Week -> count * DateUtils.WEEK_IN_MILLIS + NotificationInterval.Day -> count * DateUtils.DAY_IN_MILLIS + NotificationInterval.Hour -> count * DateUtils.HOUR_IN_MILLIS + NotificationInterval.Minute -> count * DateUtils.MINUTE_IN_MILLIS + NotificationInterval.Second -> count * DateUtils.SECOND_IN_MILLIS + } +} + +sealed class ScheduleKind { + // At specific moment of time (with repeating option) + class At(var date: Date, val repeating: Boolean): ScheduleKind() + class Interval(val interval: DateMatch): ScheduleKind() + class Every(val interval: NotificationInterval, val count: Int): ScheduleKind() +} + +@SuppressLint("SimpleDateFormat") +class NotificationSchedule(val scheduleObj: JSObject) { + val kind: ScheduleKind + // Schedule this notification to fire even if app is idled (Doze) + var whileIdle: Boolean = false + + init { + val payload = scheduleObj.getJSObject("data", JSObject()) + + when (val scheduleKind = scheduleObj.getString("kind", "")) { + "At" -> { + val dateString = payload.getString("date") + if (dateString.isNotEmpty()) { + val sdf = SimpleDateFormat(JS_DATE_FORMAT) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val at = sdf.parse(dateString) + if (at == null) { + throw Exception("could not parse `at` date") + } else { + kind = ScheduleKind.At(at, payload.getBoolean("repeating")) + } + } else { + throw Exception("`at` date cannot be empty") + } + } + "Interval" -> { + val dateMatch = onFromJson(payload) + kind = ScheduleKind.Interval(dateMatch) + } + "Every" -> { + val interval = NotificationInterval.valueOf(payload.getString("interval")) + kind = ScheduleKind.Every(interval, payload.getInteger("count", 1)) + } + else -> { + throw Exception("Unknown schedule kind $scheduleKind") + } + } + whileIdle = scheduleObj.getBoolean("allowWhileIdle", false) + } + + private fun onFromJson(onJson: JSObject): DateMatch { + val match = DateMatch() + match.year = onJson.getInteger("year") + match.month = onJson.getInteger("month") + match.day = onJson.getInteger("day") + match.weekday = onJson.getInteger("weekday") + match.hour = onJson.getInteger("hour") + match.minute = onJson.getInteger("minute") + match.second = onJson.getInteger("second") + return match + } + + fun isRemovable(): Boolean { + return when (kind) { + is ScheduleKind.At -> !kind.repeating + else -> false + } + } +} + +class DateMatch { + var year: Int? = null + var month: Int? = null + var day: Int? = null + var weekday: Int? = null + var hour: Int? = null + var minute: Int? = null + var second: Int? = null + + // Unit used to save the last used unit for a trigger. + // One of the Calendar constants values + var unit: Int? = -1 + + /** + * Gets a calendar instance pointing to the specified date. + * + * @param date The date to point. + */ + private fun buildCalendar(date: Date): Calendar { + val cal: Calendar = Calendar.getInstance() + cal.time = date + cal.set(Calendar.MILLISECOND, 0) + return cal + } + + /** + * Calculates next trigger date for + * + * @param date base date used to calculate trigger + * @return next trigger timestamp + */ + fun nextTrigger(date: Date): Long { + val current: Calendar = buildCalendar(date) + val next: Calendar = buildNextTriggerTime(date) + return postponeTriggerIfNeeded(current, next) + } + + /** + * Postpone trigger if first schedule matches the past + */ + private fun postponeTriggerIfNeeded(current: Calendar, next: Calendar): Long { + if (next.timeInMillis <= current.timeInMillis && unit != -1) { + var incrementUnit = -1 + if (unit == Calendar.YEAR || unit == Calendar.MONTH) { + incrementUnit = Calendar.YEAR + } else if (unit == Calendar.DAY_OF_MONTH) { + incrementUnit = Calendar.MONTH + } else if (unit == Calendar.DAY_OF_WEEK) { + incrementUnit = Calendar.WEEK_OF_MONTH + } else if (unit == Calendar.HOUR_OF_DAY) { + incrementUnit = Calendar.DAY_OF_MONTH + } else if (unit == Calendar.MINUTE) { + incrementUnit = Calendar.HOUR_OF_DAY + } else if (unit == Calendar.SECOND) { + incrementUnit = Calendar.MINUTE + } + if (incrementUnit != -1) { + next.set(incrementUnit, next.get(incrementUnit) + 1) + } + } + return next.timeInMillis + } + + private fun buildNextTriggerTime(date: Date): Calendar { + val next: Calendar = buildCalendar(date) + if (year != null) { + next.set(Calendar.YEAR, year ?: 0) + if (unit == -1) unit = Calendar.YEAR + } + if (month != null) { + next.set(Calendar.MONTH, month ?: 0) + if (unit == -1) unit = Calendar.MONTH + } + if (day != null) { + next.set(Calendar.DAY_OF_MONTH, day ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_MONTH + } + if (weekday != null) { + next.set(Calendar.DAY_OF_WEEK, weekday ?: 0) + if (unit == -1) unit = Calendar.DAY_OF_WEEK + } + if (hour != null) { + next.set(Calendar.HOUR_OF_DAY, hour ?: 0) + if (unit == -1) unit = Calendar.HOUR_OF_DAY + } + if (minute != null) { + next.set(Calendar.MINUTE, minute ?: 0) + if (unit == -1) unit = Calendar.MINUTE + } + if (second != null) { + next.set(Calendar.SECOND, second ?: 0) + if (unit == -1) unit = Calendar.SECOND + } + return next + } + + override fun toString(): String { + return "DateMatch{" + + "year=" + + year + + ", month=" + + month + + ", day=" + + day + + ", weekday=" + + weekday + + ", hour=" + + hour + + ", minute=" + + minute + + ", second=" + + second + + '}' + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val dateMatch = other as DateMatch + if (if (year != null) year != dateMatch.year else dateMatch.year != null) return false + if (if (month != null) month != dateMatch.month else dateMatch.month != null) return false + if (if (day != null) day != dateMatch.day else dateMatch.day != null) return false + if (if (weekday != null) weekday != dateMatch.weekday else dateMatch.weekday != null) return false + if (if (hour != null) hour != dateMatch.hour else dateMatch.hour != null) return false + if (if (minute != null) minute != dateMatch.minute else dateMatch.minute != null) return false + return if (second != null) second == dateMatch.second else dateMatch.second == null + } + + override fun hashCode(): Int { + var result = if (year != null) year.hashCode() else 0 + result = 31 * result + if (month != null) month.hashCode() else 0 + result = 31 * result + if (day != null) day.hashCode() else 0 + result = 31 * result + if (weekday != null) weekday.hashCode() else 0 + result = 31 * result + if (hour != null) hour.hashCode() else 0 + result = 31 * result + if (minute != null) minute.hashCode() else 0 + result += 31 + if (second != null) second.hashCode() else 0 + return result + } + + /** + * Transform DateMatch object to CronString + * + * @return + */ + fun toMatchString(): String { + val matchString = year.toString() + + separator + + month + + separator + + day + + separator + + weekday + + separator + + hour + + separator + + minute + + separator + + second + + separator + + unit + return matchString.replace("null", "*") + } + + companion object { + private const val separator = " " + + /** + * Create DateMatch object from stored string + * + * @param matchString + * @return + */ + fun fromMatchString(matchString: String): DateMatch { + val date = DateMatch() + val split = matchString.split(separator.toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (split.size == 7) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.unit = getValueFromCronElement(split[6]) + } + if (split.size == 8) { + date.year = getValueFromCronElement(split[0]) + date.month = getValueFromCronElement(split[1]) + date.day = getValueFromCronElement(split[2]) + date.weekday = getValueFromCronElement(split[3]) + date.hour = getValueFromCronElement(split[4]) + date.minute = getValueFromCronElement(split[5]) + date.second = getValueFromCronElement(split[6]) + date.unit = getValueFromCronElement(split[7]) + } + return date + } + + private fun getValueFromCronElement(token: String): Int? { + return try { + token.toInt() + } catch (e: NumberFormatException) { + null + } + } + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/NotificationStorage.kt b/plugins/notification/android/src/main/java/NotificationStorage.kt new file mode 100644 index 00000000..bfddfcc2 --- /dev/null +++ b/plugins/notification/android/src/main/java/NotificationStorage.kt @@ -0,0 +1,131 @@ +package app.tauri.notification + +import android.content.Context +import android.content.SharedPreferences +import app.tauri.plugin.JSObject +import org.json.JSONException +import java.text.ParseException + +// Key for private preferences +private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" +// Key used to save action types +private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE" + +class NotificationStorage(private val context: Context) { + fun appendNotifications(localNotifications: List) { + val storage = getStorage(NOTIFICATION_STORE_ID) + val editor = storage.edit() + for (request in localNotifications) { + if (request.isScheduled) { + val key: String = request.id.toString() + editor.putString(key, request.source.toString()) + } + } + editor.apply() + } + + fun getSavedNotificationIds(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + return if (all != null) { + ArrayList(all.keys) + } else ArrayList() + } + + fun getSavedNotifications(): List { + val storage = getStorage(NOTIFICATION_STORE_ID) + val all = storage.all + if (all != null) { + val notifications = ArrayList() + for (key in all.keys) { + val notificationString = all[key] as String? + val jsNotification = getNotificationFromJSONString(notificationString) + if (jsNotification != null) { + try { + val notification = + Notification.fromJSObject(jsNotification) + notifications.add(notification) + } catch (_: ParseException) { + } + } + } + return notifications + } + return ArrayList() + } + + private fun getNotificationFromJSONString(notificationString: String?): JSObject? { + if (notificationString == null) { + return null + } + val jsNotification = try { + JSObject(notificationString) + } catch (ex: JSONException) { + return null + } + return jsNotification + } + + fun getSavedNotificationAsJSObject(key: String?): JSObject? { + val storage = getStorage(NOTIFICATION_STORE_ID) + val notificationString = try { + storage.getString(key, null) + } catch (ex: ClassCastException) { + return null + } ?: return null + + val jsNotification = try { + JSObject(notificationString) + } catch (ex: JSONException) { + return null + } + return jsNotification + } + + fun getSavedNotification(key: String?): Notification? { + val jsNotification = getSavedNotificationAsJSObject(key) ?: return null + val notification = try { + Notification.fromJSObject(jsNotification) + } catch (ex: ParseException) { + return null + } + return notification + } + + fun deleteNotification(id: String?) { + val editor = getStorage(NOTIFICATION_STORE_ID).edit() + editor.remove(id) + editor.apply() + } + + private fun getStorage(key: String): SharedPreferences { + return context.getSharedPreferences(key, Context.MODE_PRIVATE) + } + + fun writeActionGroup(typesMap: Map>) { + for ((id, notificationActions) in typesMap) { + val editor = getStorage(ACTION_TYPES_ID + id).edit() + editor.clear() + editor.putInt("count", notificationActions.size) + for (i in notificationActions.indices) { + editor.putString("id$i", notificationActions[i].id) + editor.putString("title$i", notificationActions[i].title) + editor.putBoolean("input$i", notificationActions[i].input) + } + editor.apply() + } + } + + fun getActionGroup(forId: String): Array { + val storage = getStorage(ACTION_TYPES_ID + forId) + val count = storage.getInt("count", 0) + val actions: Array = arrayOfNulls(count) + for (i in 0 until count) { + val id = storage.getString("id$i", "") + val title = storage.getString("title$i", "") + val input = storage.getBoolean("input$i", false) + actions[i] = NotificationAction(id, title, input) + } + return actions + } +} \ No newline at end of file diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt new file mode 100644 index 00000000..79e67908 --- /dev/null +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -0,0 +1,569 @@ +package app.tauri.notification + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.os.UserManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import app.tauri.Logger +import app.tauri.plugin.JSObject +import app.tauri.plugin.PluginManager +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date + +// Action constants +const val NOTIFICATION_INTENT_KEY = "NotificationId" +const val NOTIFICATION_OBJ_INTENT_KEY = "LocalNotficationObject" +const val ACTION_INTENT_KEY = "NotificationUserAction" +const val NOTIFICATION_IS_REMOVABLE_KEY = "NotificationRepeating" +const val REMOTE_INPUT_KEY = "NotificationRemoteInput" +const val DEFAULT_NOTIFICATION_CHANNEL_ID = "default" +const val DEFAULT_PRESS_ACTION = "tap" + +class TauriNotificationManager( + private val storage: NotificationStorage, + private val activity: Activity?, + private val context: Context, + private val config: JSObject +) { + private var defaultSoundID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + private var defaultSmallIconID: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + + fun handleNotificationActionPerformed( + data: Intent, + notificationStorage: NotificationStorage + ): JSObject? { + Logger.debug(Logger.tags("Notification"), "Notification received: " + data.dataString) + val notificationId = + data.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (notificationId == Int.MIN_VALUE) { + Logger.debug(Logger.tags("Notification"), "Activity started without notification attached") + return null + } + val isRemovable = + data.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + notificationStorage.deleteNotification(notificationId.toString()) + } + val dataJson = JSObject() + val results = RemoteInput.getResultsFromIntent(data) + val input = results?.getCharSequence(REMOTE_INPUT_KEY) + dataJson.put("inputValue", input?.toString()) + val menuAction = data.getStringExtra(ACTION_INTENT_KEY) + dismissVisibleNotification(notificationId) + dataJson.put("actionId", menuAction) + var request: JSONObject? = null + try { + val notificationJsonString = + data.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY) + if (notificationJsonString != null) { + request = JSObject(notificationJsonString) + } + } catch (_: JSONException) { + } + dataJson.put("notification", request) + return dataJson + } + + /** + * Create notification channel + */ + fun createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Default" + val description = "Default" + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + val soundUri = getDefaultSoundUrl(context) + if (soundUri != null) { + channel.setSound(soundUri, audioAttributes) + } + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + private fun trigger(notificationManager: NotificationManagerCompat, notification: Notification): Int { + dismissVisibleNotification(notification.id) + cancelTimerForNotification(notification.id) + buildNotification(notificationManager, notification) + + return notification.id + } + + fun schedule(notification: Notification): Int { + val notificationManager = NotificationManagerCompat.from(context) + return trigger(notificationManager, notification) + } + + fun schedule(notifications: List): List { + val ids = mutableListOf() + val notificationManager = NotificationManagerCompat.from(context) + + for (notification in notifications) { + val id = trigger(notificationManager, notification) + ids.add(id) + } + + return ids + } + + // TODO Progressbar support + // TODO System categories (DO_NOT_DISTURB etc.) + // TODO use NotificationCompat.MessagingStyle for latest API + // TODO expandable notification NotificationCompat.MessagingStyle + // TODO media style notification support NotificationCompat.MediaStyle + @SuppressLint("MissingPermission") + private fun buildNotification( + notificationManager: NotificationManagerCompat, + notification: Notification, + ) { + val channelId = notification.channelId ?: DEFAULT_NOTIFICATION_CHANNEL_ID + val mBuilder = NotificationCompat.Builder( + context, channelId + ) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setAutoCancel(notification.isAutoCancel) + .setOngoing(notification.isOngoing) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setGroupSummary(notification.isGroupSummary) + if (notification.largeBody != null) { + // support multiline text + mBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.largeBody) + .setSummaryText(notification.summary) + ) + } else if (notification.inboxLines != null) { + val inboxStyle = NotificationCompat.InboxStyle() + for (line in notification.inboxLines ?: listOf()) { + inboxStyle.addLine(line) + } + inboxStyle.setBigContentTitle(notification.title) + inboxStyle.setSummaryText(notification.summary) + mBuilder.setStyle(inboxStyle) + } + val sound = notification.getSound(context, getDefaultSound(context)) + if (sound != null) { + val soundUri = Uri.parse(sound) + // Grant permission to use sound + context.grantUriPermission( + "com.android.systemui", + soundUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + mBuilder.setSound(soundUri) + mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS) + } else { + mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL) + } + val group = notification.group + if (group != null) { + mBuilder.setGroup(group) + if (notification.isGroupSummary) { + mBuilder.setSubText(notification.summary) + } + } + mBuilder.setVisibility(notification.visibility ?: NotificationCompat.VISIBILITY_PRIVATE) + mBuilder.setOnlyAlertOnce(true) + mBuilder.setSmallIcon(notification.getSmallIcon(context, getDefaultSmallIcon(context))) + mBuilder.setLargeIcon(notification.getLargeIcon(context)) + val iconColor = notification.getIconColor(config.getString("iconColor")) + if (iconColor.isNotEmpty()) { + try { + mBuilder.color = Color.parseColor(iconColor) + } catch (ex: IllegalArgumentException) { + throw Exception("Invalid color provided. Must be a hex string (ex: #ff0000") + } + } + createActionIntents(notification, mBuilder) + // notificationId is a unique int for each notification that you must define + val buildNotification = mBuilder.build() + if (notification.isScheduled) { + triggerScheduledNotification(buildNotification, notification) + } else { + notificationManager.notify(notification.id, buildNotification) + try { + NotificationPlugin.triggerNotification(notification.source ?: JSObject()) + } catch (_: JSONException) { + } + } + } + + // Create intents for open/dismiss actions + private fun createActionIntents( + notification: Notification, + mBuilder: NotificationCompat.Builder + ) { + // Open intent + val intent = buildIntent(notification, DEFAULT_PRESS_ACTION) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getActivity(context, notification.id, intent, flags) + mBuilder.setContentIntent(pendingIntent) + + // Build action types + val actionTypeId = notification.actionTypeId + if (actionTypeId != null) { + val actionGroup = storage.getActionGroup(actionTypeId) + for (notificationAction in actionGroup) { + // TODO Add custom icons to actions + val actionIntent = buildIntent(notification, notificationAction!!.id) + val actionPendingIntent = PendingIntent.getActivity( + context, + (notification.id) + notificationAction.id.hashCode(), + actionIntent, + flags + ) + val actionBuilder: NotificationCompat.Action.Builder = NotificationCompat.Action.Builder( + R.drawable.ic_transparent, + notificationAction.title, + actionPendingIntent + ) + if (notificationAction.input) { + val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel( + notificationAction.title + ).build() + actionBuilder.addRemoteInput(remoteInput) + } + mBuilder.addAction(actionBuilder.build()) + } + } + + // Dismiss intent + val dissmissIntent = Intent( + context, + NotificationDismissReceiver::class.java + ) + dissmissIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + dissmissIntent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + dissmissIntent.putExtra(ACTION_INTENT_KEY, "dismiss") + val schedule = notification.schedule + dissmissIntent.putExtra( + NOTIFICATION_IS_REMOVABLE_KEY, + schedule == null || schedule.isRemovable() + ) + flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val deleteIntent = + PendingIntent.getBroadcast(context, notification.id, dissmissIntent, flags) + mBuilder.setDeleteIntent(deleteIntent) + } + + private fun buildIntent(notification: Notification, action: String?): Intent { + val intent = if (activity != null) { + Intent(context, activity.javaClass) + } else { + val packageName = context.packageName + context.packageManager.getLaunchIntentForPackage(packageName)!! + } + intent.action = Intent.ACTION_MAIN + intent.addCategory(Intent.CATEGORY_LAUNCHER) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + intent.putExtra(NOTIFICATION_INTENT_KEY, notification.id) + intent.putExtra(ACTION_INTENT_KEY, action) + intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, notification.source.toString()) + val schedule = notification.schedule + intent.putExtra(NOTIFICATION_IS_REMOVABLE_KEY, schedule == null || schedule.isRemovable()) + return intent + } + + /** + * Build a notification trigger, such as triggering each N seconds, or + * on a certain date "shape" (such as every first of the month) + */ + // TODO support different AlarmManager.RTC modes depending on priority + @SuppressLint("SimpleDateFormat") + private fun triggerScheduledNotification(notification: android.app.Notification, request: Notification) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val schedule = request.schedule + val notificationIntent = Intent( + context, + TimedNotificationPublisher::class.java + ) + notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.id) + notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification) + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + var pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + + when (val scheduleKind = schedule?.kind) { + is ScheduleKind.At -> { + val at = scheduleKind.date + if (at.time < Date().time) { + Logger.error(Logger.tags("Notification"), "Scheduled time must be *after* current time", null) + return + } + if (scheduleKind.repeating) { + val interval: Long = at.time - Date().time + alarmManager.setRepeating(AlarmManager.RTC, at.time, interval, pendingIntent) + } else { + setExactIfPossible(alarmManager, schedule, at.time, pendingIntent) + } + } + is ScheduleKind.Interval -> { + val trigger = scheduleKind.interval.nextTrigger(Date()) + notificationIntent.putExtra(TimedNotificationPublisher.CRON_KEY, scheduleKind.interval.toMatchString()) + pendingIntent = + PendingIntent.getBroadcast(context, request.id, notificationIntent, flags) + setExactIfPossible(alarmManager, schedule, trigger, pendingIntent) + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + request.id + " will next fire at " + sdf.format(Date(trigger)) + ) + } + is ScheduleKind.Every -> { + val everyInterval = getIntervalTime(scheduleKind.interval, scheduleKind.count) + val startTime: Long = Date().time + everyInterval + alarmManager.setRepeating(AlarmManager.RTC, startTime, everyInterval, pendingIntent) + } + else -> {} + } + } + + @SuppressLint("ObsoleteSdkInt", "MissingPermission") + private fun setExactIfPossible( + alarmManager: AlarmManager, + schedule: NotificationSchedule, + trigger: Long, + pendingIntent: PendingIntent + ) { + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } + } else { + if (SDK_INT >= Build.VERSION_CODES.M && schedule.whileIdle) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, trigger, pendingIntent) + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + } + } + + fun cancel(notifications: List) { + for (id in notifications) { + dismissVisibleNotification(id) + cancelTimerForNotification(id) + storage.deleteNotification(id.toString()) + } + } + + private fun cancelTimerForNotification(notificationId: Int) { + val intent = Intent(context, TimedNotificationPublisher::class.java) + var flags = 0 + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = PendingIntent.FLAG_MUTABLE + } + val pi = PendingIntent.getBroadcast(context, notificationId, intent, flags) + if (pi != null) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pi) + } + } + + private fun dismissVisibleNotification(notificationId: Int) { + val notificationManager = NotificationManagerCompat.from( + context + ) + notificationManager.cancel(notificationId) + } + + fun areNotificationsEnabled(): Boolean { + val notificationManager = NotificationManagerCompat.from(context) + return notificationManager.areNotificationsEnabled() + } + + private fun getDefaultSoundUrl(context: Context): Uri? { + val soundId = getDefaultSound(context) + return if (soundId != AssetUtils.RESOURCE_ID_ZERO_VALUE) { + Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + soundId) + } else null + } + + private fun getDefaultSound(context: Context): Int { + if (defaultSoundID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSoundID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val soundConfigResourceName = AssetUtils.getResourceBaseName(config.getString("sound")) + if (soundConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, soundConfigResourceName, "raw") + } + defaultSoundID = resId + return resId + } + + private fun getDefaultSmallIcon(context: Context): Int { + if (defaultSmallIconID != AssetUtils.RESOURCE_ID_ZERO_VALUE) return defaultSmallIconID + var resId: Int = AssetUtils.RESOURCE_ID_ZERO_VALUE + val smallIconConfigResourceName = AssetUtils.getResourceBaseName(config.getString("icon")) + if (smallIconConfigResourceName != null) { + resId = AssetUtils.getResourceID(context, smallIconConfigResourceName, "drawable") + } + if (resId == AssetUtils.RESOURCE_ID_ZERO_VALUE) { + resId = android.R.drawable.ic_dialog_info + } + defaultSmallIconID = resId + return resId + } +} + +class NotificationDismissReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val intExtra = + intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (intExtra == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "Invalid notification dismiss operation", null) + return + } + val isRemovable = + intent.getBooleanExtra(NOTIFICATION_IS_REMOVABLE_KEY, true) + if (isRemovable) { + val notificationStorage = NotificationStorage(context) + notificationStorage.deleteNotification(intExtra.toString()) + } + } +} + +class TimedNotificationPublisher : BroadcastReceiver() { + /** + * Restore and present notification + */ + override fun onReceive(context: Context, intent: Intent) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + NOTIFICATION_KEY, + android.app.Notification::class.java + ) + } else { + getParcelableExtraLegacy(intent, NOTIFICATION_KEY) + } + notification?.`when` = System.currentTimeMillis() + val id = intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) + if (id == Int.MIN_VALUE) { + Logger.error(Logger.tags("Notification"), "No valid id supplied", null) + } + val storage = NotificationStorage(context) + val notificationJson = storage.getSavedNotificationAsJSObject(id.toString()) + if (notificationJson != null) { + NotificationPlugin.triggerNotification(notificationJson) + } + notificationManager.notify(id, notification) + if (!rescheduleNotificationIfNeeded(context, intent, id)) { + storage.deleteNotification(id.toString()) + } + } + + @Suppress("DEPRECATION") + private fun getParcelableExtraLegacy(intent: Intent, string: String): android.app.Notification? { + return intent.getParcelableExtra(string) + } + + @SuppressLint("MissingPermission", "SimpleDateFormat") + private fun rescheduleNotificationIfNeeded(context: Context, intent: Intent, id: Int): Boolean { + val dateString = intent.getStringExtra(CRON_KEY) + if (dateString != null) { + val date = DateMatch.fromMatchString(dateString) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val trigger = date.nextTrigger(Date()) + val clone = intent.clone() as Intent + var flags = PendingIntent.FLAG_CANCEL_CURRENT + if (SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + val pendingIntent = PendingIntent.getBroadcast(context, id, clone, flags) + if (SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager[AlarmManager.RTC, trigger] = pendingIntent + } else { + alarmManager.setExact(AlarmManager.RTC, trigger, pendingIntent) + } + val sdf = SimpleDateFormat("yyyy/MM/dd HH:mm:ss") + Logger.debug( + Logger.tags("Notification"), + "notification " + id + " will next fire at " + sdf.format(Date(trigger)) + ) + return true + } + return false + } + + companion object { + var NOTIFICATION_KEY = "NotificationPublisher.notification" + var CRON_KEY = "NotificationPublisher.cron" + } +} + +class LocalNotificationRestoreReceiver : BroadcastReceiver() { + @SuppressLint("ObsoleteSdkInt") + override fun onReceive(context: Context, intent: Intent) { + if (SDK_INT >= Build.VERSION_CODES.N) { + val um = context.getSystemService( + UserManager::class.java + ) + if (um == null || !um.isUserUnlocked) return + } + val storage = NotificationStorage(context) + val ids = storage.getSavedNotificationIds() + val notifications = mutableListOf() + val updatedNotifications = mutableListOf() + for (id in ids) { + val notification = storage.getSavedNotification(id) ?: continue + val schedule = notification.schedule + if (schedule != null && schedule.kind is ScheduleKind.At) { + val at: Date = schedule.kind.date + if (at.before(Date())) { + // modify the scheduled date in order to show notifications that would have been delivered while device was off. + val newDateTime = Date().time + 15 * 1000 + schedule.kind.date = Date(newDateTime) + updatedNotifications.add(notification) + } + } + notifications.add(notification) + } + if (updatedNotifications.size > 0) { + storage.appendNotifications(updatedNotifications) + } + + val notificationManager = TauriNotificationManager(storage, null, context, PluginManager.loadConfig(context, "notification")) + notificationManager.schedule(notifications) + } +} diff --git a/plugins/notification/android/src/main/res/drawable/ic_transparent.xml b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml new file mode 100644 index 00000000..fc1779e2 --- /dev/null +++ b/plugins/notification/android/src/main/res/drawable/ic_transparent.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 7a186795..0efa168d 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -24,7 +24,7 @@ * @module */ -import { invoke } from "@tauri-apps/api/tauri"; +import { invoke, transformCallback } from '@tauri-apps/api/tauri' /** * Options to send a notification. @@ -32,12 +32,269 @@ import { invoke } from "@tauri-apps/api/tauri"; * @since 1.0.0 */ interface Options { - /** Notification title. */ - title: string; - /** Optional notification body. */ - body?: string; - /** Optional notification icon. */ - icon?: string; + /** + * The notification identifier to reference this object later. Must be a 32-bit integer. + */ + id?: number + /** + * Identifier of the {@link Channel} that deliveres this notification. + * + * If the channel does not exist, the notification won't fire. + * Make sure the channel exists with {@link listChannels} and {@link createChannel}. + */ + channelId?: string + /** + * Notification title. + */ + title: string + /** + * Optional notification body. + * */ + body?: string + /** + * Schedule this notification to fire on a later time or a fixed interval. + */ + schedule?: Schedule + /** + * Multiline text. + * Changes the notification style to big text. + * Cannot be used with `inboxLines`. + */ + largeBody?: string + /** + * Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + */ + summary?: string + /** + * Defines an action type for this notification. + */ + actionTypeId?: string + /** + * Identifier used to group multiple notifications. + * + * https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier + */ + group?: string + /** + * Instructs the system that this notification is the summary of a group on Android. + */ + groupSummary?: boolean + /** + * The sound resource name. Only available on mobile. + */ + sound?: string + /** + * List of lines to add to the notification. + * Changes the notification style to inbox. + * Cannot be used with `largeBody`. + * + * Only supports up to 5 lines. + */ + inboxLines?: string[] + /** + * Notification icon. + * + * On Android the icon must be placed in the app's `res/drawable` folder. + */ + icon?: string + /** + * Notification large icon (Android). + * + * The icon must be placed in the app's `res/drawable` folder. + */ + largeIcon?: string + /** + * Icon color on Android. + */ + iconColor?: string + /** + * Notification attachments. + */ + attachments?: Attachment[] + /** + * Extra payload to store in the notification. + */ + extra?: { [key: string]: unknown } + /** + * If true, the notification cannot be dismissed by the user on Android. + * + * An application service must manage the dismissal of the notification. + * It is typically used to indicate a background task that is pending (e.g. a file download) + * or the user is engaged with (e.g. playing music). + */ + ongoing?: boolean + /** + * Automatically cancel the notification when the user clicks on it. + */ + autoCancel?: boolean + /** + * Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + */ + silent?: boolean + /** + * Notification visibility. + */ + visibility?: Visibility + /** + * Sets the number of items this notification represents on Android. + */ + number?: number +} + +type ScheduleInterval = { + year?: number + month?: number + day?: number + /** + * 1 - Sunday + * 2 - Monday + * 3 - Tuesday + * 4 - Wednesday + * 5 - Thursday + * 6 - Friday + * 7 - Saturday + */ + weekday?: number + hour?: number + minute?: number + second?: number +} + +enum ScheduleEvery { + Year = 'Year', + Month = 'Month', + TwoWeeks = 'TwoWeeks', + Week = 'Week', + Day = 'Day', + Hour = 'Hour', + Minute = 'Minute', + /** + * Not supported on iOS. + */ + Second = 'Second' +} + +type ScheduleData = { + kind: 'At', + data: { + date: Date + repeating: boolean + } +} | { + kind: 'Interval', + data: ScheduleInterval +} | { + kind: 'Every', + data: { + interval: ScheduleEvery + } +} + +class Schedule { + kind: string + data: unknown + + private constructor(schedule: ScheduleData) { + this.kind = schedule.kind + this.data = schedule.data + } + + static at(date: Date, repeating = false) { + return new Schedule({ kind: 'At', data: { date, repeating } }) + } + + static interval(interval: ScheduleInterval) { + return new Schedule({ kind: 'Interval', data: interval }) + } + + static every(kind: ScheduleEvery) { + return new Schedule({ kind: 'Every', data: { interval: kind } }) + } +} + +/** + * Attachment of a notification. + */ +interface Attachment { + /** Attachment identifier. */ + id: string + /** Attachment URL. Accepts the `asset` and `file` protocols. */ + url: string +} + +interface Action { + id: string + title: string + requiresAuthentication?: boolean + foreground?: boolean + destructive?: boolean + input?: boolean + inputButtonTitle?: string + inputPlaceholder?: string +} + +interface ActionType { + /** + * The identifier of this action type + */ + id: string + /** + * The list of associated actions + */ + actions: Action[] + hiddenPreviewsBodyPlaceholder?: string, + customDismissAction?: boolean, + allowInCarPlay?: boolean, + hiddenPreviewsShowTitle?: boolean, + hiddenPreviewsShowSubtitle?: boolean, +} + +interface PendingNotification { + id: number + title?: string + body?: string + schedule: Schedule +} + +interface ActiveNotification { + id: number + tag?: string + title?: string + body?: string + group?: string + groupSummary: boolean + data: Record + extra: Record + attachments: Attachment[] + actionTypeId?: string + schedule?: Schedule + sound?: string +} + +enum Importance { + None = 0, + Min, + Low, + Default, + High +} + +enum Visibility { + Secret = -1, + Private, + Public +} + +interface Channel { + id: string + name: string + description?: string + sound?: string + lights?: boolean + lightColor?: string + vibration?: boolean + importance?: Importance + visibility?: Visibility } /** Possible permission values. */ @@ -108,6 +365,241 @@ function sendNotification(options: Options | string): void { } } -export type { Options, Permission }; +/** + * Register actions that are performed when the user clicks on the notification. + * + * @example + * ```typescript + * import { registerActionTypes } from '@tauri-apps/api/notification'; + * await registerActionTypes([{ + * id: 'tauri', + * actions: [{ + * id: 'my-action', + * title: 'Settings' + * }] + * }]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function registerActionTypes(types: ActionType[]): Promise { + return invoke('plugin:notification|register_action_types', { types }) +} + +/** + * Retrieves the list of pending notifications. + * + * @example + * ```typescript + * import { pending } from '@tauri-apps/api/notification'; + * const pendingNotifications = await pending(); + * ``` + * + * @returns A promise resolving to the list of pending notifications. + * + * @since 2.0.0 + */ +async function pending(): Promise { + return invoke('plugin:notification|get_pending') +} + +/** + * Cancels the pending notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancel(notifications: number[]): Promise { + return invoke('plugin:notification|cancel', { notifications }) +} + +/** + * Cancels all pending notifications. + * + * @example + * ```typescript + * import { cancelAll } from '@tauri-apps/api/notification'; + * await cancelAll(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function cancelAll(): Promise { + return invoke('plugin:notification|cancel') +} -export { sendNotification, requestPermission, isPermissionGranted }; +/** + * Retrieves the list of active notifications. + * + * @example + * ```typescript + * import { active } from '@tauri-apps/api/notification'; + * const activeNotifications = await active(); + * ``` + * + * @returns A promise resolving to the list of active notifications. + * + * @since 2.0.0 + */ +async function active(): Promise { + return invoke('plugin:notification|get_active') +} + +/** + * Removes the active notifications with the given list of identifiers. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/api/notification'; + * await cancel([-34234, 23432, 4311]) + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeActive(notifications: number[]): Promise { + return invoke('plugin:notification|remove_active', { notifications }) +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { removeAllActive } from '@tauri-apps/api/notification'; + * await removeAllActive() + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeAllActive(): Promise { + return invoke('plugin:notification|remove_active') +} + +/** + * Removes all active notifications. + * + * @example + * ```typescript + * import { createChannel, Importance, Visibility } from '@tauri-apps/api/notification'; + * await createChannel({ + * id: 'new-messages', + * name: 'New Messages', + * lights: true, + * vibration: true, + * importance: Importance.Default, + * visibility: Visibility.Private + * }); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function createChannel(channel: Channel): Promise { + return invoke('plugin:notification|create_channel', { ...channel }) +} + +/** + * Removes the channel with the given identifier. + * + * @example + * ```typescript + * import { removeChannel } from '@tauri-apps/api/notification'; + * await removeChannel(); + * ``` + * + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.0.0 + */ +async function removeChannel(id: string): Promise { + return invoke('plugin:notification|delete_channel', { id }) +} + +/** + * Retrieves the list of notification channels. + * + * @example + * ```typescript + * import { channels } from '@tauri-apps/api/notification'; + * const notificationChannels = await channels(); + * ``` + * + * @returns A promise resolving to the list of notification channels. + * + * @since 2.0.0 + */ +async function channels(): Promise { + return invoke('plugin:notification|getActive') +} + +class EventChannel { + id: number + unregisterFn: (channel: EventChannel) => Promise + + constructor(id: number, unregisterFn: (channel: EventChannel) => Promise) { + this.id = id + this.unregisterFn = unregisterFn + } + + toJSON(): string { + return `__CHANNEL__:${this.id}` + } + + async unregister(): Promise { + return this.unregisterFn(this) + } +} + +// TODO: use addPluginListener API on @tauri-apps/api/tauri 2.0.0-alpha.4 +async function onNotificationReceived(cb: (notification: Options) => void): Promise { + const channelId = transformCallback(cb) + const handler = new EventChannel(channelId, (channel) => invoke('plugin:notification|remove_listener', { event: 'notification', channelId: channel.id })) + return invoke('plugin:notification|register_listener', { event: 'notification', handler }).then(() => handler) +} + +// TODO: use addPluginListener API on @tauri-apps/api/tauri 2.0.0-alpha.4 +async function onAction(cb: (notification: Options) => void): Promise { + const channelId = transformCallback(cb) + const handler = new EventChannel(channelId, (channel) => invoke('plugin:notification|remove_listener', { event: 'actionPerformed', channelId: channel.id })) + return invoke('plugin:notification|register_listener', { event: 'actionPerformed', handler }).then(() => handler) +} + + +export type { Attachment, Options, Permission, Action, ActionType, PendingNotification, ActiveNotification, Channel } + +export { + Importance, + Visibility, + sendNotification, + requestPermission, + isPermissionGranted, + registerActionTypes, + pending, + cancel, + cancelAll, + active, + removeActive, + removeAllActive, + createChannel, + removeChannel, + channels, + + onNotificationReceived, + onAction +} diff --git a/plugins/notification/ios/Package.swift b/plugins/notification/ios/Package.swift index ff9991fa..bfcaf338 100644 --- a/plugins/notification/ios/Package.swift +++ b/plugins/notification/ios/Package.swift @@ -4,16 +4,16 @@ import PackageDescription let package = Package( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", platforms: [ .iOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", type: .static, - targets: ["tauri-plugin-{{ plugin_name }}"]), + targets: ["tauri-plugin-notification"]), ], dependencies: [ .package(name: "Tauri", path: "../.tauri/tauri-api") @@ -22,7 +22,7 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "tauri-plugin-{{ plugin_name }}", + name: "tauri-plugin-notification", dependencies: [ .byName(name: "Tauri") ], diff --git a/plugins/notification/ios/Sources/Notification.swift b/plugins/notification/ios/Sources/Notification.swift new file mode 100644 index 00000000..52b1016f --- /dev/null +++ b/plugins/notification/ios/Sources/Notification.swift @@ -0,0 +1,272 @@ +import Tauri +import UserNotifications + +enum NotificationError: LocalizedError { + case contentNoId + case contentNoTitle + case contentNoBody + case triggerRepeatIntervalTooShort + case attachmentNoId + case attachmentNoUrl + case attachmentFileNotFound(path: String) + case attachmentUnableToCreate(String) + case pastScheduledTime + case invalidDate(String) + + var errorDescription: String? { + switch self { + case .contentNoId: + return "Missing notification identifier" + case .contentNoTitle: + return "Missing notification title" + case .contentNoBody: + return "Missing notification body" + case .triggerRepeatIntervalTooShort: + return "Schedule interval too short, must be a least 1 minute" + case .attachmentNoId: + return "Missing attachment identifier" + case .attachmentNoUrl: + return "Missing attachment URL" + case .attachmentFileNotFound(let path): + return "Unable to find file \(path) for attachment" + case .attachmentUnableToCreate(let error): + return "Failed to create attachment: \(error)" + case .pastScheduledTime: + return "Scheduled time must be *after* current time" + case .invalidDate(let date): + return "Could not parse date \(date)" + } + } +} + +func makeNotificationContent(_ notification: JSObject) throws -> UNNotificationContent { + guard let title = notification["title"] as? String else { + throw NotificationError.contentNoTitle + } + guard let body = notification["body"] as? String else { + throw NotificationError.contentNoBody + } + + let extra = notification["extra"] as? JSObject ?? [:] + let schedule = notification["schedule"] as? JSObject ?? [:] + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString(forKey: title, arguments: nil) + content.body = NSString.localizedUserNotificationString( + forKey: body, + arguments: nil) + + content.userInfo = [ + "__EXTRA__": extra, + "__SCHEDULE__": schedule, + ] + + if let actionTypeId = notification["actionTypeId"] as? String { + content.categoryIdentifier = actionTypeId + } + + if let threadIdentifier = notification["group"] as? String { + content.threadIdentifier = threadIdentifier + } + + if let summaryArgument = notification["summary"] as? String { + content.summaryArgument = summaryArgument + } + + if let sound = notification["sound"] as? String { + content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) + } + + if let attachments = notification["attachments"] as? [JSObject] { + content.attachments = try makeAttachments(attachments) + } + + return content +} + +func makeAttachments(_ attachments: [JSObject]) throws -> [UNNotificationAttachment] { + var createdAttachments = [UNNotificationAttachment]() + + for attachment in attachments { + guard let id = attachment["id"] as? String else { + throw NotificationError.attachmentNoId + } + guard let url = attachment["url"] as? String else { + throw NotificationError.attachmentNoUrl + } + guard let urlObject = makeAttachmentUrl(url) else { + throw NotificationError.attachmentFileNotFound(path: url) + } + + let options = attachment["options"] as? JSObject ?? [:] + + do { + let newAttachment = try UNNotificationAttachment( + identifier: id, url: urlObject, options: makeAttachmentOptions(options)) + createdAttachments.append(newAttachment) + } catch { + throw NotificationError.attachmentUnableToCreate(error.localizedDescription) + } + } + + return createdAttachments +} + +func makeAttachmentUrl(_ path: String) -> URL? { + return URL(string: path) +} + +func makeAttachmentOptions(_ options: JSObject) -> JSObject { + var opts: JSObject = [:] + + if let iosUNNotificationAttachmentOptionsTypeHintKey = options[ + "iosUNNotificationAttachmentOptionsTypeHintKey"] as? String + { + opts[UNNotificationAttachmentOptionsTypeHintKey] = iosUNNotificationAttachmentOptionsTypeHintKey + } + if let iosUNNotificationAttachmentOptionsThumbnailHiddenKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailHiddenKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] = + iosUNNotificationAttachmentOptionsThumbnailHiddenKey + } + if let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailClippingRectKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = + iosUNNotificationAttachmentOptionsThumbnailClippingRectKey + } + if let iosUNNotificationAttachmentOptionsThumbnailTimeKey = options[ + "iosUNNotificationAttachmentOptionsThumbnailTimeKey"] as? String + { + opts[UNNotificationAttachmentOptionsThumbnailTimeKey] = + iosUNNotificationAttachmentOptionsThumbnailTimeKey + } + return opts +} + +func handleScheduledNotification(_ schedule: JSObject) throws + -> UNNotificationTrigger? +{ + let kind = schedule["kind"] as? String ?? "" + let payload = schedule["data"] as? JSObject ?? [:] + switch kind { + case "At": + let date = payload["date"] as? String ?? "" + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + if let at = dateFormatter.date(from: date) { + let repeats = payload["repeats"] as? Bool ?? false + + let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) + + if dateInfo.date! < Date() { + throw NotificationError.pastScheduledTime + } + + let dateInterval = DateInterval(start: Date(), end: dateInfo.date!) + + // Notifications that repeat have to be at least a minute between each other + if repeats && dateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: dateInterval.duration, repeats: repeats) + + } else { + throw NotificationError.invalidDate(date) + } + case "Interval": + let dateComponents = getDateComponents(payload) + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + case "Every": + let interval = payload["interval"] as? String ?? "" + let count = schedule["count"] as? Int ?? 1 + + if let repeatDateInterval = getRepeatDateInterval(interval, count) { + // Notifications that repeat have to be at least a minute between each other + if repeatDateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: repeatDateInterval.duration, repeats: true) + } + + default: + return nil + } + + return nil +} + +/// Given our schedule format, return a DateComponents object +/// that only contains the components passed in. + +func getDateComponents(_ at: JSObject) -> DateComponents { + // var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date()) + // dateInfo.calendar = Calendar.current + var dateInfo = DateComponents() + + if let year = at["year"] as? Int { + dateInfo.year = year + } + if let month = at["month"] as? Int { + dateInfo.month = month + } + if let day = at["day"] as? Int { + dateInfo.day = day + } + if let hour = at["hour"] as? Int { + dateInfo.hour = hour + } + if let minute = at["minute"] as? Int { + dateInfo.minute = minute + } + if let second = at["second"] as? Int { + dateInfo.second = second + } + if let weekday = at["weekday"] as? Int { + dateInfo.weekday = weekday + } + return dateInfo +} + +/// Compute the difference between the string representation of a date +/// interval and today. For example, if every is "month", then we +/// return the interval between today and a month from today. + +func getRepeatDateInterval(_ every: String, _ count: Int) -> DateInterval? { + let cal = Calendar.current + let now = Date() + switch every { + case "Year": + let newDate = cal.date(byAdding: .year, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Month": + let newDate = cal.date(byAdding: .month, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "TwoWeeks": + let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)! + return DateInterval(start: now, end: newDate) + case "Week": + let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Day": + let newDate = cal.date(byAdding: .day, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Hour": + let newDate = cal.date(byAdding: .hour, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Minute": + let newDate = cal.date(byAdding: .minute, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case "Second": + let newDate = cal.date(byAdding: .second, value: count, to: now)! + return DateInterval(start: now, end: newDate) + default: + return nil + } +} diff --git a/plugins/notification/ios/Sources/NotificationCategory.swift b/plugins/notification/ios/Sources/NotificationCategory.swift new file mode 100644 index 00000000..74a1e194 --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationCategory.swift @@ -0,0 +1,131 @@ +import Tauri +import UserNotifications + +enum CategoryError: LocalizedError { + case noId + case noActionId + + var errorDescription: String? { + switch self { + case .noId: + return "Action type `id` missing" + case .noActionId: + return "Action `id` missing" + } + } +} + +public func makeCategories(_ actionTypes: [JSObject]) throws { + var createdCategories = [UNNotificationCategory]() + + let generalCategory = UNNotificationCategory( + identifier: "GENERAL", + actions: [], + intentIdentifiers: [], + options: .customDismissAction) + + createdCategories.append(generalCategory) + for type in actionTypes { + guard let id = type["id"] as? String else { + throw CategoryError.noId + } + let hiddenBodyPlaceholder = type["hiddenPreviewsBodyPlaceholder"] as? String ?? "" + let actions = type["actions"] as? [JSObject] ?? [] + + let newActions = try makeActions(actions) + + // Create the custom actions for the TIMER_EXPIRED category. + var newCategory: UNNotificationCategory? + + newCategory = UNNotificationCategory( + identifier: id, + actions: newActions, + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: hiddenBodyPlaceholder, + options: makeCategoryOptions(type)) + + createdCategories.append(newCategory!) + } + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories(Set(createdCategories)) +} + +func makeActions(_ actions: [JSObject]) throws -> [UNNotificationAction] { + var createdActions = [UNNotificationAction]() + + for action in actions { + guard let id = action["id"] as? String else { + throw CategoryError.noActionId + } + let title = action["title"] as? String ?? "" + let input = action["input"] as? Bool ?? false + + var newAction: UNNotificationAction + if input { + let inputButtonTitle = action["inputButtonTitle"] as? String + let inputPlaceholder = action["inputPlaceholder"] as? String ?? "" + + if inputButtonTitle != nil { + newAction = UNTextInputNotificationAction( + identifier: id, + title: title, + options: makeActionOptions(action), + textInputButtonTitle: inputButtonTitle!, + textInputPlaceholder: inputPlaceholder) + } else { + newAction = UNTextInputNotificationAction( + identifier: id, title: title, options: makeActionOptions(action)) + } + } else { + // Create the custom actions for the TIMER_EXPIRED category. + newAction = UNNotificationAction( + identifier: id, + title: title, + options: makeActionOptions(action)) + } + createdActions.append(newAction) + } + + return createdActions +} + +func makeActionOptions(_ action: JSObject) -> UNNotificationActionOptions { + let foreground = action["foreground"] as? Bool ?? false + let destructive = action["destructive"] as? Bool ?? false + let requiresAuthentication = action["requiresAuthentication"] as? Bool ?? false + + if foreground { + return .foreground + } + if destructive { + return .destructive + } + if requiresAuthentication { + return .authenticationRequired + } + return UNNotificationActionOptions(rawValue: 0) +} + +func makeCategoryOptions(_ type: JSObject) -> UNNotificationCategoryOptions { + let customDismiss = type["customDismissAction"] as? Bool ?? false + let carPlay = type["allowInCarPlay"] as? Bool ?? false + let hiddenPreviewsShowTitle = type["hiddenPreviewsShowTitle"] as? Bool ?? false + let hiddenPreviewsShowSubtitle = type["hiddenPreviewsShowSubtitle"] as? Bool ?? false + + if customDismiss { + return .customDismissAction + } + if carPlay { + return .allowInCarPlay + } + + if hiddenPreviewsShowTitle { + return .hiddenPreviewsShowTitle + } + if hiddenPreviewsShowSubtitle { + return .hiddenPreviewsShowSubtitle + } + + return UNNotificationCategoryOptions(rawValue: 0) +} diff --git a/plugins/notification/ios/Sources/NotificationHandler.swift b/plugins/notification/ios/Sources/NotificationHandler.swift new file mode 100644 index 00000000..1f7cb8ba --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationHandler.swift @@ -0,0 +1,116 @@ +import Tauri +import UserNotifications + +public class NotificationHandler: NSObject, NotificationHandlerProtocol { + + public weak var plugin: Plugin? + + private var notificationsMap = [String: JSObject]() + + public func saveNotification(_ key: String, _ notification: JSObject) { + notificationsMap.updateValue(notification, forKey: key) + } + + public func requestPermissions(with completion: ((Bool, Error?) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.badge, .alert, .sound]) { (granted, error) in + completion?(granted, error) + } + } + + public func checkPermissions(with completion: ((UNAuthorizationStatus) -> Void)? = nil) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + completion?(settings.authorizationStatus) + } + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + let notificationData = makeNotificationRequestJSObject(notification.request) + self.plugin?.trigger("notification", data: notificationData) + + if let options = notificationsMap[notification.request.identifier] { + let silent = options["silent"] as? Bool ?? false + if silent { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + } + + return [ + .badge, + .sound, + .alert, + ] + } + + public func didReceive(response: UNNotificationResponse) { + var data = JSObject() + + let originalNotificationRequest = response.notification.request + let actionId = response.actionIdentifier + + // We turn the two default actions (open/dismiss) into generic strings + if actionId == UNNotificationDefaultActionIdentifier { + data["actionId"] = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + data["actionId"] = "dismiss" + } else { + data["actionId"] = actionId + } + + // If the type of action was for an input type, get the value + if let inputType = response as? UNTextInputNotificationResponse { + data["inputValue"] = inputType.userText + } + + data["notification"] = makeNotificationRequestJSObject(originalNotificationRequest) + + self.plugin?.trigger("actionPerformed", data: data) + } + + /** + * Turn a UNNotificationRequest into a JSObject to return back to the client. + */ + func makeNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + let notificationRequest = notificationsMap[request.identifier] ?? [:] + var notification = makePendingNotificationRequestJSObject(request) + notification["sound"] = notificationRequest["sound"] ?? "" + notification["actionTypeId"] = request.content.categoryIdentifier + notification["attachments"] = notificationRequest["attachments"] ?? [JSObject]() + return notification + } + + func makePendingNotificationRequestJSObject(_ request: UNNotificationRequest) -> JSObject { + var notification: JSObject = [ + "id": Int(request.identifier) ?? -1, + "title": request.content.title, + "body": request.content.body, + ] + + if let userInfo = JSTypes.coerceDictionaryToJSObject(request.content.userInfo) { + var extra = userInfo["__EXTRA__"] as? JSObject ?? userInfo + + // check for any dates and convert them to strings + for (key, value) in extra { + if let date = value as? Date { + let dateString = ISO8601DateFormatter().string(from: date) + extra[key] = dateString + } + } + + notification["extra"] = extra + + if var schedule = userInfo["__SCHEDULE__"] as? JSObject { + // convert schedule at date to string + if let date = schedule["at"] as? Date { + let dateString = ISO8601DateFormatter().string(from: date) + schedule["at"] = dateString + } + + notification["schedule"] = schedule + } + } + + return notification + } +} diff --git a/plugins/notification/ios/Sources/NotificationManager.swift b/plugins/notification/ios/Sources/NotificationManager.swift new file mode 100644 index 00000000..857636fb --- /dev/null +++ b/plugins/notification/ios/Sources/NotificationManager.swift @@ -0,0 +1,39 @@ +import Foundation +import UserNotifications + +@objc public protocol NotificationHandlerProtocol { + func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions + func didReceive(response: UNNotificationResponse) +} + +@objc public class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + public weak var notificationHandler: NotificationHandlerProtocol? + + override init() { + super.init() + let center = UNUserNotificationCenter.current() + center.delegate = self + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + var presentationOptions: UNNotificationPresentationOptions? = nil + + if notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + presentationOptions = notificationHandler?.willPresent(notification: notification) + } + + completionHandler(presentationOptions ?? []) + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + if response.notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) != true { + notificationHandler?.didReceive(response: response) + } + + completionHandler() + } +} diff --git a/plugins/notification/ios/Sources/NotificationPlugin.swift b/plugins/notification/ios/Sources/NotificationPlugin.swift index 3d520a92..217c999d 100644 --- a/plugins/notification/ios/Sources/NotificationPlugin.swift +++ b/plugins/notification/ios/Sources/NotificationPlugin.swift @@ -1,24 +1,209 @@ +import SwiftRs +import Tauri import UIKit +import UserNotifications import WebKit -import Tauri -import SwiftRs + +enum ShowNotificationError: LocalizedError { + case noId + case make(Error) + case create(Error) + + var errorDescription: String? { + switch self { + case .noId: + return "notification `id` missing" + case .make(let error): + return "Unable to make notification: \(error)" + case .create(let error): + return "Unable to create notification: \(error)" + } + } +} + +func showNotification(invoke: Invoke, notification: JSObject) + throws -> UNNotificationRequest +{ + guard let identifier = notification["id"] as? Int else { + throw ShowNotificationError.noId + } + + var content: UNNotificationContent + do { + content = try makeNotificationContent(notification) + } catch { + throw ShowNotificationError.make(error) + } + + var trigger: UNNotificationTrigger? + + do { + if let schedule = notification["schedule"] as? JSObject { + try trigger = handleScheduledNotification(schedule) + } + } catch { + throw ShowNotificationError.create(error) + } + + // Schedule the request. + let request = UNNotificationRequest( + identifier: "\(identifier)", content: content, trigger: trigger + ) + + let center = UNUserNotificationCenter.current() + center.add(request) { (error: Error?) in + if let theError = error { + invoke.reject(theError.localizedDescription) + } + } + + return request +} class NotificationPlugin: Plugin { - @objc public func requestPermission(_ invoke: Invoke) throws { - invoke.resolve(["permissionState": "granted"]) - } - - @objc public func permissionState(_ invoke: Invoke) throws { - invoke.resolve(["permissionState": "granted"]) - } - - @objc public func notify(_ invoke: Invoke) throws { - // TODO - invoke.resolve() - } + let notificationHandler = NotificationHandler() + let notificationManager = NotificationManager() + + override init() { + super.init() + notificationManager.notificationHandler = notificationHandler + notificationHandler.plugin = self + } + + @objc public func show(_ invoke: Invoke) throws { + let request = try showNotification(invoke: invoke, notification: invoke.data) + notificationHandler.saveNotification(request.identifier, invoke.data) + invoke.resolve([ + "id": Int(request.identifier) ?? -1 + ]) + } + + @objc public func batch(_ invoke: Invoke) throws { + guard let notifications = invoke.getArray("notifications", JSObject.self) else { + invoke.reject("`notifications` array is required") + return + } + var ids = [Int]() + + for notification in notifications { + let request = try showNotification(invoke: invoke, notification: notification) + notificationHandler.saveNotification(request.identifier, notification) + ids.append(Int(request.identifier) ?? -1) + } + + invoke.resolve([ + "notifications": ids + ]) + } + + @objc public override func requestPermissions(_ invoke: Invoke) { + notificationHandler.requestPermissions { granted, error in + guard error == nil else { + invoke.reject(error!.localizedDescription) + return + } + invoke.resolve(["permissionState": granted ? "granted" : "denied"]) + } + } + + @objc public override func checkPermissions(_ invoke: Invoke) { + notificationHandler.checkPermissions { status in + let permission: String + + switch status { + case .authorized, .ephemeral, .provisional: + permission = "granted" + case .denied: + permission = "denied" + case .notDetermined: + permission = "default" + @unknown default: + permission = "default" + } + + invoke.resolve(["permissionState": permission]) + } + } + + @objc func cancel(_ invoke: Invoke) { + guard let notifications = invoke.getArray("notifications", NSNumber.self), + notifications.count > 0 + else { + invoke.reject("`notifications` input is required") + return + } + + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: notifications.map({ (id) -> String in + return id.stringValue + }) + ) + invoke.resolve() + } + + @objc func getPending(_ invoke: Invoke) { + UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: { + (notifications) in + let ret = notifications.compactMap({ [weak self] (notification) -> JSObject? in + return self?.notificationHandler.makePendingNotificationRequestJSObject(notification) + }) + + invoke.resolve([ + "notifications": ret + ]) + }) + } + + @objc func registerActionTypes(_ invoke: Invoke) throws { + guard let types = invoke.getArray("types", JSObject.self) else { + return + } + try makeCategories(types) + invoke.resolve() + } + + @objc func removeActive(_ invoke: Invoke) { + if let notifications = invoke.getArray("notifications", JSObject.self) { + let ids = notifications.map { "\($0["id"] ?? "")" } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + invoke.resolve() + } else { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + DispatchQueue.main.async(execute: { + UIApplication.shared.applicationIconBadgeNumber = 0 + }) + invoke.resolve() + } + } + + @objc func getActive(_ invoke: Invoke) { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { + (notifications) in + let ret = notifications.map({ (notification) -> [String: Any] in + return self.notificationHandler.makeNotificationRequestJSObject( + notification.request) + }) + invoke.resolve([ + "notifications": ret + ]) + }) + } + + @objc func createChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func deleteChannel(_ invoke: Invoke) { + invoke.reject("not implemented") + } + + @objc func listChannels(_ invoke: Invoke) { + invoke.reject("not implemented") + } + } @_cdecl("init_plugin_notification") -func initPlugin(name: SRString, webview: WKWebView?) { - Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin()) +func initPlugin() -> Plugin { + return NotificationPlugin() } diff --git a/plugins/notification/src/commands.rs b/plugins/notification/src/commands.rs index 710235c1..4af85585 100644 --- a/plugins/notification/src/commands.rs +++ b/plugins/notification/src/commands.rs @@ -2,30 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use serde::Deserialize; use tauri::{command, AppHandle, Runtime, State}; -use crate::{Notification, PermissionState, Result}; - -/// The options for the notification API. -#[derive(Debug, Clone, Deserialize)] -pub struct NotificationOptions { - /// The notification title. - pub title: String, - /// The notification body. - pub body: Option, - /// The notification icon. - pub icon: Option, -} +use crate::{Notification, NotificationData, PermissionState, Result}; #[command] pub(crate) async fn is_permission_granted( _app: AppHandle, notification: State<'_, Notification>, -) -> Result { - notification - .permission_state() - .map(|s| s == PermissionState::Granted) +) -> Result> { + let state = notification.permission_state()?; + match state { + PermissionState::Granted => Ok(Some(true)), + PermissionState::Denied => Ok(Some(false)), + PermissionState::Unknown => Ok(None), + } } #[command] @@ -40,15 +31,9 @@ pub(crate) async fn request_permission( pub(crate) async fn notify( _app: AppHandle, notification: State<'_, Notification>, - options: NotificationOptions, + options: NotificationData, ) -> Result<()> { - let mut builder = notification.builder().title(options.title); - if let Some(body) = options.body { - builder = builder.body(body); - } - if let Some(icon) = options.icon { - builder = builder.icon(icon); - } - + let mut builder = notification.builder(); + builder.data = options; builder.show() } diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index cb63758a..6e566fe2 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -30,16 +30,6 @@ use desktop::Notification; #[cfg(mobile)] use mobile::Notification; -#[derive(Debug, Default, Serialize)] -struct NotificationData { - /// The notification title. - title: Option, - /// The notification body. - body: Option, - /// The notification icon. - icon: Option, -} - /// The notification builder. #[derive(Debug)] pub struct NotificationBuilder { @@ -47,7 +37,7 @@ pub struct NotificationBuilder { app: AppHandle, #[cfg(mobile)] handle: PluginHandle, - data: NotificationData, + pub(crate) data: NotificationData, } impl NotificationBuilder { @@ -67,6 +57,21 @@ impl NotificationBuilder { } } + /// Sets the notification identifier. + pub fn id(mut self, id: i32) -> Self { + self.data.id = id; + self + } + + /// Identifier of the {@link Channel} that deliveres this notification. + /// + /// If the channel does not exist, the notification won't fire. + /// Make sure the channel exists with {@link listChannels} and {@link createChannel}. + pub fn channel_id(mut self, id: impl Into) -> Self { + self.data.channel_id.replace(id.into()); + self + } + /// Sets the notification title. pub fn title(mut self, title: impl Into) -> Self { self.data.title.replace(title.into()); @@ -79,11 +84,119 @@ impl NotificationBuilder { self } - /// Sets the notification icon. + /// Schedule this notification to fire on a later time or a fixed interval. + pub fn schedule(mut self, schedule: Schedule) -> Self { + self.data.schedule.replace(schedule); + self + } + + /// Multiline text. + /// Changes the notification style to big text. + /// Cannot be used with `inboxLines`. + pub fn large_body(mut self, large_body: impl Into) -> Self { + self.data.large_body.replace(large_body.into()); + self + } + + /// Detail text for the notification with `largeBody`, `inboxLines` or `groupSummary`. + pub fn summary(mut self, summary: impl Into) -> Self { + self.data.summary.replace(summary.into()); + self + } + + /// Defines an action type for this notification. + pub fn action_type_id(mut self, action_type_id: impl Into) -> Self { + self.data.action_type_id.replace(action_type_id.into()); + self + } + + /// Identifier used to group multiple notifications. + /// + /// https://developer.apple.com/documentation/usernotifications/unmutablenotificationcontent/1649872-threadidentifier + pub fn group(mut self, group: impl Into) -> Self { + self.data.group.replace(group.into()); + self + } + + /// Instructs the system that this notification is the summary of a group on Android. + pub fn group_summary(mut self) -> Self { + self.data.group_summary = true; + self + } + + /// The sound resource name. Only available on mobile. + pub fn sound(mut self, sound: impl Into) -> Self { + self.data.sound.replace(sound.into()); + self + } + + /// Append an inbox line to the notification. + /// Changes the notification style to inbox. + /// Cannot be used with `largeBody`. + /// + /// Only supports up to 5 lines. + pub fn inbox_line(mut self, line: impl Into) -> Self { + self.data.inbox_lines.push(line.into()); + self + } + + /// Notification icon. + /// + /// On Android the icon must be placed in the app's `res/drawable` folder. pub fn icon(mut self, icon: impl Into) -> Self { self.data.icon.replace(icon.into()); self } + + /// Notification large icon (Android). + /// + /// The icon must be placed in the app's `res/drawable` folder. + pub fn large_icon(mut self, large_icon: impl Into) -> Self { + self.data.large_icon.replace(large_icon.into()); + self + } + + /// Icon color on Android. + pub fn icon_color(mut self, icon_color: impl Into) -> Self { + self.data.icon_color.replace(icon_color.into()); + self + } + + /// Append an attachment to the notification. + pub fn attachment(mut self, attachment: Attachment) -> Self { + self.data.attachments.push(attachment); + self + } + + /// Adds an extra payload to store in the notification. + pub fn extra(mut self, key: impl Into, value: impl Serialize) -> Self { + self.data + .extra + .insert(key.into(), serde_json::to_value(value).unwrap()); + self + } + + /// If true, the notification cannot be dismissed by the user on Android. + /// + /// An application service must manage the dismissal of the notification. + /// It is typically used to indicate a background task that is pending (e.g. a file download) + /// or the user is engaged with (e.g. playing music). + pub fn ongoing(mut self) -> Self { + self.data.ongoing = true; + self + } + + /// Automatically cancel the notification when the user clicks on it. + pub fn auto_cancel(mut self) -> Self { + self.data.auto_cancel = true; + self + } + + /// Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + pub fn silent(mut self) -> Self { + self.data.silent = true; + self + } } /// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the notification APIs. diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index abd196ed..83513eef 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -10,6 +10,8 @@ use tauri::{ use crate::models::*; +use std::collections::HashMap; + #[cfg(target_os = "android")] const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; @@ -31,7 +33,8 @@ pub fn init( impl crate::NotificationBuilder { pub fn show(self) -> crate::Result<()> { self.handle - .run_mobile_plugin("notify", self.data) + .run_mobile_plugin::("show", self.data) + .map(|_| ()) .map_err(Into::into) } } @@ -46,17 +49,121 @@ impl Notification { pub fn request_permission(&self) -> crate::Result { self.0 - .run_mobile_plugin::("requestPermission", ()) + .run_mobile_plugin::("requestPermissions", ()) .map(|r| r.permission_state) .map_err(Into::into) } pub fn permission_state(&self) -> crate::Result { self.0 - .run_mobile_plugin::("permissionState", ()) + .run_mobile_plugin::("checkPermissions", ()) .map(|r| r.permission_state) .map_err(Into::into) } + + pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("types", types); + self.0 + .run_mobile_plugin("registerActionTypes", args) + .map_err(Into::into) + } + + pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.0 + .run_mobile_plugin("removeActive", args) + .map_err(Into::into) + } + + pub fn active(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getActive", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + pub fn remove_all_active(&self) -> crate::Result<()> { + self.0 + .run_mobile_plugin("removeActive", ()) + .map_err(Into::into) + } + + pub fn pending(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("getPending", ()) + .map(|r| r.notifications) + .map_err(Into::into) + } + + /// Cancel pending notifications. + pub fn cancel(&self, notifications: Vec) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("notifications", notifications); + self.0.run_mobile_plugin("cancel", args).map_err(Into::into) + } + + /// Cancel all pending notifications. + pub fn cancel_all(&self) -> crate::Result<()> { + self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { + self.0 + .run_mobile_plugin("createChannel", channel) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { + let mut args = HashMap::new(); + args.insert("id", id.into()); + self.0 + .run_mobile_plugin("deleteChannel", args) + .map_err(Into::into) + } + + #[cfg(target_os = "android")] + pub fn list_channels(&self) -> crate::Result> { + self.0 + .run_mobile_plugin::("listChannels", ()) + .map(|r| r.channels) + .map_err(Into::into) + } +} + +#[cfg(target_os = "android")] +#[derive(Deserialize)] +struct ListChannelsResult { + channels: Vec, +} + +#[derive(Deserialize)] +struct PendingResponse { + notifications: Vec, +} + +#[derive(Deserialize)] +struct ActiveResponse { + notifications: Vec, +} + +#[derive(Deserialize)] +struct ShowResponse { + #[allow(dead_code)] + id: i32, } #[derive(Deserialize)] diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index d1cf0e4b..df2ae5c1 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -2,10 +2,201 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::fmt::Display; +use std::{collections::HashMap, fmt::Display}; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + id: String, + url: Url, +} + +impl Attachment { + pub fn new(id: impl Into, url: Url) -> Self { + Self { id: id.into(), url } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleInterval { + pub year: Option, + pub month: Option, + pub day: Option, + pub weekday: Option, + pub hour: Option, + pub minute: Option, + pub second: Option, +} + +#[derive(Debug)] +pub enum ScheduleEvery { + Year, + Month, + TwoWeeks, + Week, + Day, + Hour, + Minute, + Second, +} + +impl Display for ScheduleEvery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Year => "Year", + Self::Month => "Month", + Self::TwoWeeks => "TwoWeeks", + Self::Week => "Week", + Self::Day => "Day", + Self::Hour => "Hour", + Self::Minute => "Minute", + Self::Second => "Second", + } + ) + } +} + +impl Serialize for ScheduleEvery { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} + +impl<'de> Deserialize<'de> for ScheduleEvery { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "year" => Ok(Self::Year), + "month" => Ok(Self::Month), + "twoweeks" => Ok(Self::TwoWeeks), + "week" => Ok(Self::Week), + "day" => Ok(Self::Day), + "hour" => Ok(Self::Hour), + "minute" => Ok(Self::Minute), + "second" => Ok(Self::Second), + _ => Err(DeError::custom(format!("unknown every kind '{s}'"))), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum Schedule { + At { + #[serde( + serialize_with = "iso8601::serialize", + deserialize_with = "time::serde::iso8601::deserialize" + )] + date: time::OffsetDateTime, + #[serde(default)] + repeating: bool, + }, + Interval(ScheduleInterval), + Every { + interval: ScheduleEvery, + }, +} + +// custom ISO-8601 serialization that does not use 6 digits for years. +mod iso8601 { + use serde::{ser::Error as _, Serialize, Serializer}; + use time::{ + format_description::well_known::iso8601::{Config, EncodedConfig}, + format_description::well_known::Iso8601, + OffsetDateTime, + }; + + const SERDE_CONFIG: EncodedConfig = Config::DEFAULT.encode(); + + pub fn serialize( + datetime: &OffsetDateTime, + serializer: S, + ) -> Result { + datetime + .format(&Iso8601::) + .map_err(S::Error::custom)? + .serialize(serializer) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationData { + #[serde(default = "default_id")] + pub(crate) id: i32, + pub(crate) channel_id: Option, + pub(crate) title: Option, + pub(crate) body: Option, + pub(crate) schedule: Option, + pub(crate) large_body: Option, + pub(crate) summary: Option, + pub(crate) action_type_id: Option, + pub(crate) group: Option, + #[serde(default)] + pub(crate) group_summary: bool, + pub(crate) sound: Option, + #[serde(default)] + pub(crate) inbox_lines: Vec, + pub(crate) icon: Option, + pub(crate) large_icon: Option, + pub(crate) icon_color: Option, + #[serde(default)] + pub(crate) attachments: Vec, + #[serde(default)] + pub(crate) extra: HashMap, + #[serde(default)] + pub(crate) ongoing: bool, + #[serde(default)] + pub(crate) auto_cancel: bool, + #[serde(default)] + pub(crate) silent: bool, +} + +fn default_id() -> i32 { + rand::random() +} + +impl Default for NotificationData { + fn default() -> Self { + Self { + id: default_id(), + channel_id: None, + title: None, + body: None, + schedule: None, + large_body: None, + summary: None, + action_type_id: None, + group: None, + group_summary: false, + sound: None, + inbox_lines: Vec::new(), + icon: None, + large_icon: None, + icon_color: None, + attachments: Vec::new(), + extra: Default::default(), + ongoing: false, + auto_cancel: false, + silent: false, + } + } +} + /// Permission state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionState { @@ -13,6 +204,8 @@ pub enum PermissionState { Granted, /// Permission access has been denied. Denied, + /// Unknown state. Must request permission. + Unknown, } impl Display for PermissionState { @@ -20,6 +213,7 @@ impl Display for PermissionState { match self { Self::Granted => write!(f, "granted"), Self::Denied => write!(f, "denied"), + Self::Unknown => write!(f, "Unknown"), } } } @@ -42,7 +236,274 @@ impl<'de> Deserialize<'de> for PermissionState { match s.to_lowercase().as_str() { "granted" => Ok(Self::Granted), "denied" => Ok(Self::Denied), + "default" => Ok(Self::Unknown), _ => Err(DeError::custom(format!("unknown permission state '{s}'"))), } } } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingNotification { + id: i32, + title: Option, + body: Option, + schedule: Schedule, +} + +impl PendingNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn schedule(&self) -> &Schedule { + &self.schedule + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveNotification { + id: i32, + tag: Option, + title: Option, + body: Option, + group: Option, + #[serde(default)] + group_summary: bool, + #[serde(default)] + data: HashMap, + #[serde(default)] + extra: HashMap, + #[serde(default)] + attachments: Vec, + action_type_id: Option, + schedule: Option, + sound: Option, +} + +impl ActiveNotification { + pub fn id(&self) -> i32 { + self.id + } + + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn body(&self) -> Option<&str> { + self.body.as_deref() + } + + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + pub fn group_summary(&self) -> bool { + self.group_summary + } + + pub fn data(&self) -> &HashMap { + &self.data + } + + pub fn extra(&self) -> &HashMap { + &self.extra + } + + pub fn attachments(&self) -> &[Attachment] { + &self.attachments + } + + pub fn action_type_id(&self) -> Option<&str> { + self.action_type_id.as_deref() + } + + pub fn schedule(&self) -> Option<&Schedule> { + self.schedule.as_ref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionType { + id: String, + actions: Vec, + hidden_previews_body_placeholder: Option, + custom_dismiss_action: bool, + allow_in_car_play: bool, + hidden_previews_show_title: bool, + hidden_previews_show_subtitle: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Action { + id: String, + title: String, + requires_authentication: bool, + foreground: bool, + destructive: bool, + input: bool, + input_button_title: Option, + input_placeholder: Option, +} + +#[cfg(target_os = "android")] +pub use android::*; + +#[cfg(target_os = "android")] +mod android { + use serde::{Deserialize, Serialize}; + use serde_repr::{Deserialize_repr, Serialize_repr}; + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(u8)] + pub enum Importance { + None = 0, + Min = 1, + Low = 2, + Default = 3, + High = 4, + } + + impl Default for Importance { + fn default() -> Self { + Self::Default + } + } + + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] + #[repr(i8)] + pub enum Visibility { + Secret = -1, + Private = 0, + Public = 1, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Channel { + id: String, + name: String, + description: Option, + sound: Option, + lights: bool, + light_color: Option, + vibration: bool, + importance: Importance, + visibility: Option, + } + + #[derive(Debug)] + pub struct ChannelBuilder(Channel); + + impl Channel { + pub fn builder(id: impl Into, name: impl Into) -> ChannelBuilder { + ChannelBuilder(Self { + id: id.into(), + name: name.into(), + description: None, + sound: None, + lights: false, + light_color: None, + vibration: false, + importance: Default::default(), + visibility: None, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn sound(&self) -> Option<&str> { + self.sound.as_deref() + } + + pub fn lights(&self) -> bool { + self.lights + } + + pub fn light_color(&self) -> Option<&str> { + self.light_color.as_deref() + } + + pub fn vibration(&self) -> bool { + self.vibration + } + + pub fn importance(&self) -> Importance { + self.importance + } + + pub fn visibility(&self) -> Option { + self.visibility + } + } + + impl ChannelBuilder { + pub fn description(mut self, description: impl Into) -> Self { + self.0.description.replace(description.into()); + self + } + + pub fn sound(mut self, sound: impl Into) -> Self { + self.0.sound.replace(sound.into()); + self + } + + pub fn lights(mut self, lights: bool) -> Self { + self.0.lights = lights; + self + } + + pub fn light_color(mut self, color: impl Into) -> Self { + self.0.light_color.replace(color.into()); + self + } + + pub fn vibration(mut self, vibration: bool) -> Self { + self.0.vibration = vibration; + self + } + + pub fn importance(mut self, importance: Importance) -> Self { + self.0.importance = importance; + self + } + + pub fn visibility(mut self, visibility: Visibility) -> Self { + self.0.visibility.replace(visibility); + self + } + + pub fn build(self) -> Channel { + self.0 + } + } +}