Merge remote-tracking branch 'origin/v2' into feat/example

pull/317/head
Lucas Nogueira 2 years ago
commit 6f65b98697
No known key found for this signature in database
GPG Key ID: FFEA6C72E73482F1

@ -3,12 +3,12 @@
"pkgManagers": { "pkgManagers": {
"javascript": { "javascript": {
"version": true, "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"] "publish": ["pnpm build", "pnpm publish --access public --no-git-checks"]
}, },
"rust": { "rust": {
"version": true, "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": [ "publish": [
{ {
"command": "cargo package --no-verify", "command": "cargo package --no-verify",

@ -1,3 +1,4 @@
target target
node_modules node_modules
dist dist
dist-js

@ -6,7 +6,8 @@
"extends": [ "extends": [
"prettier", "prettier",
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended" "plugin:@typescript-eslint/recommended",
"plugin:security/recommended"
], ],
"overrides": [], "overrides": [],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",

@ -6,14 +6,16 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
push: push:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/audit-javascript.yml" - ".github/workflows/audit-javascript.yml"
- "**/pnpm-lock.yaml" - "**/pnpm-lock.yaml"
- "**/package.json" - "**/package.json"
pull_request: pull_request:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/audit-javascript.yml" - ".github/workflows/audit-javascript.yml"
- "**/pnpm-lock.yaml" - "**/pnpm-lock.yaml"

@ -6,14 +6,16 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
push: push:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/audit-rust.yml" - ".github/workflows/audit-rust.yml"
- "**/Cargo.lock" - "**/Cargo.lock"
- "**/Cargo.toml" - "**/Cargo.toml"
pull_request: pull_request:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/audit-rust.yml" - ".github/workflows/audit-rust.yml"
- "**/Cargo.lock" - "**/Cargo.lock"

@ -3,7 +3,7 @@ name: version or publish
on: on:
push: push:
branches: branches:
- dev - v1
jobs: jobs:
version-or-publish: version-or-publish:

@ -3,7 +3,8 @@ name: Lint JavaScript
on: on:
push: push:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/lint-javascript.yml" - ".github/workflows/lint-javascript.yml"
- "plugins/*/guest-js/**" - "plugins/*/guest-js/**"
@ -13,7 +14,8 @@ on:
- "**/package.json" - "**/package.json"
pull_request: pull_request:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/lint-javascript.yml" - ".github/workflows/lint-javascript.yml"
- "plugins/*/guest-js/**" - "plugins/*/guest-js/**"

@ -3,14 +3,16 @@ name: Lint Rust
on: on:
push: push:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/lint-rust.yml" - ".github/workflows/lint-rust.yml"
- "plugins/*/src/**" - "plugins/*/src/**"
- "**/Cargo.toml" - "**/Cargo.toml"
pull_request: pull_request:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/lint-rust.yml" - ".github/workflows/lint-rust.yml"
- "plugins/*/src/**" - "plugins/*/src/**"
@ -32,7 +34,7 @@ jobs:
- name: install webkit2gtk and libudev for [authenticator] - name: install webkit2gtk and libudev for [authenticator]
run: | run: |
sudo apt-get update 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 - name: Install clippy with stable toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

@ -3,7 +3,8 @@ name: Check MSRV
on: on:
push: push:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/msrv-check.yml" - ".github/workflows/msrv-check.yml"
- "plugins/*/src/**" - "plugins/*/src/**"
@ -11,7 +12,8 @@ on:
- "**/Cargo.lock" - "**/Cargo.lock"
pull_request: pull_request:
branches: branches:
- dev - v1
- v2
paths: paths:
- ".github/workflows/msrv-check.yml" - ".github/workflows/msrv-check.yml"
- "plugins/*/src/**" - "plugins/*/src/**"
@ -34,7 +36,7 @@ jobs:
- name: install webkit2gtk and libudev for [authenticator] - name: install webkit2gtk and libudev for [authenticator]
run: | run: |
sudo apt-get update 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 - uses: dtolnay/rust-toolchain@1.64.0

@ -4,8 +4,8 @@ on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- dev - v1
- next - v2
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

@ -0,0 +1,56 @@
#!/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");
}
});
});

4
Cargo.lock generated

@ -4927,11 +4927,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
"rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr",
"tauri", "tauri",
"tauri-build", "tauri-build",
"thiserror", "thiserror",
"time 0.3.20",
"url",
"win7-notifications", "win7-notifications",
] ]

@ -21,6 +21,7 @@
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^15.7.0", "eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.1.1", "eslint-plugin-promise": "^6.1.1",
"eslint-plugin-security": "^1.7.1",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"rollup": "^3.20.4", "rollup": "^3.20.4",
"typescript": "^5.0.4" "typescript": "^5.0.4"

@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
[dependencies] [dependencies]
tauri-plugin-authenticator = "0.1" tauri-plugin-authenticator = "0.1"
# or through git # or through git
tauri-plugin-authenticator = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-authenticator = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -28,11 +28,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-authenticator#next pnpm add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-authenticator#next npm add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-authenticator#next yarn add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
``` ```
## Usage ## Usage
@ -53,7 +53,7 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { Authenticator } from 'tauri-plugin-authenticator-api'; import { Authenticator } from "tauri-plugin-authenticator-api";
const auth = new Authenticator(); const auth = new Authenticator();
auth.init(); // initialize transports auth.init(); // initialize transports
@ -63,16 +63,21 @@ const arr = new Uint32Array(32);
window.crypto.getRandomValues(arr); window.crypto.getRandomValues(arr);
const b64 = btoa(String.fromCharCode.apply(null, arr)); const b64 = btoa(String.fromCharCode.apply(null, arr));
// web-safe base64 // web-safe base64
const challenge = b64.replace(/\+/g, '-').replace(/\//g, '_'); const challenge = b64.replace(/\+/g, "-").replace(/\//g, "_");
const domain = 'https://tauri.app'; const domain = "https://tauri.app";
// attempt to register with the security key // attempt to register with the security key
const json = await auth.register(challenge, domain); const json = await auth.register(challenge, domain);
const registerResult = JSON.parse(json); const registerResult = JSON.parse(json);
// verify te registration was successfull // verify te registration was successfull
const r2 = await auth.verifyRegistration(challenge, app, registerResult.registerData, registerResult.clientData); const r2 = await auth.verifyRegistration(
challenge,
app,
registerResult.registerData,
registerResult.clientData
);
const j2 = JSON.parse(r2); const j2 = JSON.parse(r2);
// sign some data // sign some data
@ -80,10 +85,17 @@ const json = await auth.sign(challenge, app, keyHandle);
const signData = JSON.parse(json); const signData = JSON.parse(json);
// verify the signature again // verify the signature again
const counter = await auth.verifySignature(challenge, app, signData.signData, clientData, keyHandle, pubkey); const counter = await auth.verifySignature(
challenge,
app,
signData.signData,
clientData,
keyHandle,
pubkey
);
if (counter && counter > 0) { if (counter && counter > 0) {
console.log('SUCCESS!'); console.log("SUCCESS!");
} }
``` ```

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-autostart#next pnpm add https://github.com/tauri-apps/tauri-plugin-autostart#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-autostart#next npm add https://github.com/tauri-apps/tauri-plugin-autostart#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-autostart#next yarn add https://github.com/tauri-apps/tauri-plugin-autostart#v2
``` ```
## Usage ## Usage
@ -53,7 +53,7 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { enable, isEnabled, disable } from 'tauri-plugin-autostart-api'; import { enable, isEnabled, disable } from "tauri-plugin-autostart-api";
await enable(); await enable();

@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-cli#next pnpm add https://github.com/tauri-apps/tauri-plugin-cli#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-cli#next npm add https://github.com/tauri-apps/tauri-plugin-cli#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-cli#next yarn add https://github.com/tauri-apps/tauri-plugin-cli#v2
``` ```
## Usage ## Usage

@ -8,7 +8,7 @@
* @module * @module
*/ */
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from "@tauri-apps/api/tauri";
/** /**
* @since 1.0.0 * @since 1.0.0
@ -19,32 +19,32 @@ interface ArgMatch {
* boolean if flag * boolean if flag
* string[] or null if takes multiple values * string[] or null if takes multiple values
*/ */
value: string | boolean | string[] | null value: string | boolean | string[] | null;
/** /**
* Number of occurrences * Number of occurrences
*/ */
occurrences: number occurrences: number;
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface SubcommandMatch { interface SubcommandMatch {
name: string name: string;
matches: CliMatches matches: CliMatches;
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface CliMatches { interface CliMatches {
args: Record<string, ArgMatch> args: Record<string, ArgMatch>;
subcommand: SubcommandMatch | null 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` * 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 * @example
* ```typescript * ```typescript
* import { getMatches } from 'tauri-plugin-cli-api'; * import { getMatches } from 'tauri-plugin-cli-api';
@ -64,9 +64,9 @@ interface CliMatches {
* @since 1.0.0 * @since 1.0.0
*/ */
async function getMatches(): Promise<CliMatches> { async function getMatches(): Promise<CliMatches> {
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 };

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-clipboard = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-clipboard = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-clipboard#next pnpm add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-clipboard#next npm add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-clipboard#next yarn add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
``` ```
## Usage ## Usage

@ -24,14 +24,14 @@
* @module * @module
*/ */
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from "@tauri-apps/api/tauri";
interface Clip<K, T> { interface Clip<K, T> {
kind: K kind: K;
options: T options: T;
} }
type ClipResponse = Clip<'PlainText', string> type ClipResponse = Clip<"PlainText", string>;
/** /**
* Writes plain text to the clipboard. * Writes plain text to the clipboard.
@ -46,16 +46,19 @@ type ClipResponse = Clip<'PlainText', string>
* *
* @since 1.0.0. * @since 1.0.0.
*/ */
async function writeText(text: string, opts?: { label?: string }): Promise<void> { async function writeText(
return invoke('plugin:clipboard|write', { text: string,
opts?: { label?: string }
): Promise<void> {
return invoke("plugin:clipboard|write", {
data: { data: {
kind: 'PlainText', kind: "PlainText",
options: { options: {
label: opts?.label, label: opts?.label,
text text,
} },
} },
}) });
} }
/** /**
@ -68,8 +71,8 @@ async function writeText(text: string, opts?: { label?: string }): Promise<void>
* @since 1.0.0. * @since 1.0.0.
*/ */
async function readText(): Promise<string> { async function readText(): Promise<string> {
const kind: ClipResponse = await invoke('plugin:clipboard|read') const kind: ClipResponse = await invoke("plugin:clipboard|read");
return kind.options return kind.options;
} }
export { writeText, readText } export { writeText, readText };

@ -29,4 +29,4 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^1.2.0" "@tauri-apps/api": "^1.2.0"
} }
} }

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-dialog-api = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-dialog-api = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:

@ -19,4 +19,4 @@
"svelte": "^3.49.0", "svelte": "^3.49.0",
"vite": "^3.0.2" "vite": "^3.0.2"
} }
} }

@ -1,116 +1,116 @@
{ {
"images" : [ "images": [
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-20x20@2x.png", "filename": "AppIcon-20x20@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-20x20@3x.png", "filename": "AppIcon-20x20@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-29x29@2x-1.png", "filename": "AppIcon-29x29@2x-1.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-29x29@3x.png", "filename": "AppIcon-29x29@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-40x40@2x.png", "filename": "AppIcon-40x40@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-40x40@3x.png", "filename": "AppIcon-40x40@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "60x60", "size": "60x60",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-60x60@2x.png", "filename": "AppIcon-60x60@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "60x60", "size": "60x60",
"idiom" : "iphone", "idiom": "iphone",
"filename" : "AppIcon-60x60@3x.png", "filename": "AppIcon-60x60@3x.png",
"scale" : "3x" "scale": "3x"
}, },
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-20x20@1x.png", "filename": "AppIcon-20x20@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "20x20", "size": "20x20",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-20x20@2x-1.png", "filename": "AppIcon-20x20@2x-1.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-29x29@1x.png", "filename": "AppIcon-29x29@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "29x29", "size": "29x29",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-29x29@2x.png", "filename": "AppIcon-29x29@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-40x40@1x.png", "filename": "AppIcon-40x40@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "40x40", "size": "40x40",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-40x40@2x-1.png", "filename": "AppIcon-40x40@2x-1.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "76x76", "size": "76x76",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-76x76@1x.png", "filename": "AppIcon-76x76@1x.png",
"scale" : "1x" "scale": "1x"
}, },
{ {
"size" : "76x76", "size": "76x76",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-76x76@2x.png", "filename": "AppIcon-76x76@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "83.5x83.5", "size": "83.5x83.5",
"idiom" : "ipad", "idiom": "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png", "filename": "AppIcon-83.5x83.5@2x.png",
"scale" : "2x" "scale": "2x"
}, },
{ {
"size" : "1024x1024", "size": "1024x1024",
"idiom" : "ios-marketing", "idiom": "ios-marketing",
"filename" : "AppIcon-512@2x.png", "filename": "AppIcon-512@2x.png",
"scale" : "1x" "scale": "1x"
} }
], ],
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }

@ -1,6 +1,6 @@
{ {
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }

@ -60,7 +60,7 @@ targets:
base: base:
ENABLE_BITCODE: false ENABLE_BITCODE: false
ARCHS: [arm64, arm64-sim] 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=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]: $(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) 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 basedOnDependencyAnalysis: false
outputFiles: outputFiles:
- $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/libapp.a - $(SRCROOT)/target/aarch64-apple-ios/${CONFIGURATION}/deps/libapp.a
- $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libapp.a - $(SRCROOT)/target/x86_64-apple-ios/${CONFIGURATION}/deps/libapp.a

@ -4,7 +4,11 @@ import { internalIpV4 } from "internal-ip";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(async () => { 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 { return {
plugins: [svelte()], plugins: [svelte()],
@ -13,17 +17,17 @@ export default defineConfig(async () => {
clearScreen: false, clearScreen: false,
// tauri expects a fixed port, fail if that port is not available // tauri expects a fixed port, fail if that port is not available
server: { server: {
host: '0.0.0.0', host: "0.0.0.0",
port: 5173, port: 5173,
strictPort: true, strictPort: true,
hmr: { hmr: {
protocol: 'ws', protocol: "ws",
host, host,
port: 5183 port: 5183,
}, },
fs: { fs: {
allow: ['.', '../../tooling/api/dist'] allow: [".", "../../tooling/api/dist"],
} },
}, },
// to make use of `TAURI_DEBUG` and other env variables // to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
@ -36,5 +40,5 @@ export default defineConfig(async () => {
// produce sourcemaps for debug builds // produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG, sourcemap: !!process.env.TAURI_DEBUG,
}, },
} };
}); });

@ -2,18 +2,18 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from "@tauri-apps/api/tauri";
interface FileResponse { interface FileResponse {
base64Data?: string base64Data?: string;
duration?: number duration?: number;
height?: number height?: number;
width?: number width?: number;
mimeType?: string mimeType?: string;
modifiedAt?: number modifiedAt?: number;
name?: string name?: string;
path: string path: string;
size: number size: number;
} }
/** /**
@ -23,7 +23,7 @@ interface FileResponse {
*/ */
interface DialogFilter { interface DialogFilter {
/** Filter name. */ /** Filter name. */
name: string name: string;
/** /**
* Extensions to filter, without a `.` prefix. * Extensions to filter, without a `.` prefix.
* @example * @example
@ -31,7 +31,7 @@ interface DialogFilter {
* extensions: ['svg', 'png'] * extensions: ['svg', 'png']
* ``` * ```
*/ */
extensions: string[] extensions: string[];
} }
/** /**
@ -41,20 +41,20 @@ interface DialogFilter {
*/ */
interface OpenDialogOptions { interface OpenDialogOptions {
/** The title of the dialog window. */ /** The title of the dialog window. */
title?: string title?: string;
/** The filters of the dialog. */ /** The filters of the dialog. */
filters?: DialogFilter[] filters?: DialogFilter[];
/** Initial directory or file path. */ /** Initial directory or file path. */
defaultPath?: string defaultPath?: string;
/** Whether the dialog allows multiple selection or not. */ /** Whether the dialog allows multiple selection or not. */
multiple?: boolean multiple?: boolean;
/** Whether the dialog is a directory selection or not. */ /** 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. * If `directory` is true, indicates that it will be read recursively later.
* Defines whether subdirectories will be allowed on the scope or not. * Defines whether subdirectories will be allowed on the scope or not.
*/ */
recursive?: boolean recursive?: boolean;
} }
/** /**
@ -64,15 +64,15 @@ interface OpenDialogOptions {
*/ */
interface SaveDialogOptions { interface SaveDialogOptions {
/** The title of the dialog window. */ /** The title of the dialog window. */
title?: string title?: string;
/** The filters of the dialog. */ /** The filters of the dialog. */
filters?: DialogFilter[] filters?: DialogFilter[];
/** /**
* Initial directory or file path. * Initial directory or file path.
* If it's a directory path, the dialog interface will change to that folder. * 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. * 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 { interface MessageDialogOptions {
/** The title of the dialog. Defaults to the app name. */ /** The title of the dialog. Defaults to the app name. */
title?: string title?: string;
/** The type of the dialog. Defaults to `info`. */ /** The type of the dialog. Defaults to `info`. */
type?: 'info' | 'warning' | 'error' type?: "info" | "warning" | "error";
/** The label of the confirm button. */ /** The label of the confirm button. */
okLabel?: string okLabel?: string;
} }
interface ConfirmDialogOptions { interface ConfirmDialogOptions {
/** The title of the dialog. Defaults to the app name. */ /** The title of the dialog. Defaults to the app name. */
title?: string title?: string;
/** The type of the dialog. Defaults to `info`. */ /** The type of the dialog. Defaults to `info`. */
type?: 'info' | 'warning' | 'error' type?: "info" | "warning" | "error";
/** The label of the confirm button. */ /** The label of the confirm button. */
okLabel?: string okLabel?: string;
/** The label of the cancel button. */ /** The label of the cancel button. */
cancelLabel?: string cancelLabel?: string;
} }
async function open( async function open(
options?: OpenDialogOptions & { multiple?: false, directory?: false } options?: OpenDialogOptions & { multiple?: false; directory?: false }
): Promise<null | FileResponse> ): Promise<null | FileResponse>;
async function open( async function open(
options?: OpenDialogOptions & { multiple?: true, directory?: false } options?: OpenDialogOptions & { multiple?: true; directory?: false }
): Promise<null | FileResponse[]> ): Promise<null | FileResponse[]>;
async function open( async function open(
options?: OpenDialogOptions & { multiple?: false, directory?: true } options?: OpenDialogOptions & { multiple?: false; directory?: true }
): Promise<null | string> ): Promise<null | string>;
async function open( async function open(
options?: OpenDialogOptions & { multiple?: true, directory?: true } options?: OpenDialogOptions & { multiple?: true; directory?: true }
): Promise<null | string[]> ): Promise<null | string[]>;
/** /**
* Open a file/directory selection dialog. * Open a file/directory selection dialog.
* *
@ -165,11 +165,11 @@ async function open(
async function open( async function open(
options: OpenDialogOptions = {} options: OpenDialogOptions = {}
): Promise<null | string | string[] | FileResponse | FileResponse[]> { ): Promise<null | string | string[] | FileResponse | FileResponse[]> {
if (typeof options === 'object') { if (typeof options === "object") {
Object.freeze(options) 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 * @since 1.0.0
*/ */
async function save(options: SaveDialogOptions = {}): Promise<string | null> { async function save(options: SaveDialogOptions = {}): Promise<string | null> {
if (typeof options === 'object') { if (typeof options === "object") {
Object.freeze(options) Object.freeze(options);
} }
return invoke('plugin:dialog|save', { options }) return invoke("plugin:dialog|save", { options });
} }
/** /**
@ -225,13 +225,13 @@ async function message(
message: string, message: string,
options?: string | MessageDialogOptions options?: string | MessageDialogOptions
): Promise<void> { ): Promise<void> {
const opts = typeof options === 'string' ? { title: options } : options const opts = typeof options === "string" ? { title: options } : options;
return invoke('plugin:dialog|message', { return invoke("plugin:dialog|message", {
message: message.toString(), message: message.toString(),
title: opts?.title?.toString(), title: opts?.title?.toString(),
type_: opts?.type, type_: opts?.type,
okButtonLabel: opts?.okLabel?.toString() okButtonLabel: opts?.okLabel?.toString(),
}) });
} }
/** /**
@ -254,14 +254,14 @@ async function ask(
message: string, message: string,
options?: string | ConfirmDialogOptions options?: string | ConfirmDialogOptions
): Promise<boolean> { ): Promise<boolean> {
const opts = typeof options === 'string' ? { title: options } : options const opts = typeof options === "string" ? { title: options } : options;
return invoke('plugin:dialog|ask', { return invoke("plugin:dialog|ask", {
message: message.toString(), message: message.toString(),
title: opts?.title?.toString(), title: opts?.title?.toString(),
type_: opts?.type, type_: opts?.type,
okButtonLabel: opts?.okLabel?.toString() ?? 'Yes', okButtonLabel: opts?.okLabel?.toString() ?? "Yes",
cancelButtonLabel: opts?.cancelLabel?.toString() ?? 'No', cancelButtonLabel: opts?.cancelLabel?.toString() ?? "No",
}) });
} }
/** /**
@ -284,14 +284,14 @@ async function confirm(
message: string, message: string,
options?: string | ConfirmDialogOptions options?: string | ConfirmDialogOptions
): Promise<boolean> { ): Promise<boolean> {
const opts = typeof options === 'string' ? { title: options } : options const opts = typeof options === "string" ? { title: options } : options;
return invoke('plugin:dialog|confirm', { return invoke("plugin:dialog|confirm", {
message: message.toString(), message: message.toString(),
title: opts?.title?.toString(), title: opts?.title?.toString(),
type_: opts?.type, type_: opts?.type,
okButtonLabel: opts?.okLabel?.toString() ?? 'Ok', okButtonLabel: opts?.okLabel?.toString() ?? "Ok",
cancelButtonLabel: opts?.cancelLabel?.toString() ?? 'Cancel', cancelButtonLabel: opts?.cancelLabel?.toString() ?? "Cancel",
}) });
} }
export type { export type {
@ -299,7 +299,7 @@ export type {
OpenDialogOptions, OpenDialogOptions,
SaveDialogOptions, SaveDialogOptions,
MessageDialogOptions, MessageDialogOptions,
ConfirmDialogOptions ConfirmDialogOptions,
} };
export { open, save, message, ask, confirm } export { open, save, message, ask, confirm };

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-fs-watch#next pnpm add https://github.com/tauri-apps/tauri-plugin-fs-watch#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-fs-watch#next npm add https://github.com/tauri-apps/tauri-plugin-fs-watch#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-fs-watch#next yarn add https://github.com/tauri-apps/tauri-plugin-fs-watch#v2
``` ```
## Usage ## Usage
@ -51,16 +51,24 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { watch, watchImmediate } from 'tauri-plugin-fs-watch-api'; import { watch, watchImmediate } from "tauri-plugin-fs-watch-api";
// can also watch an array of paths // can also watch an array of paths
const stopWatching = await watch('/path/to/something', { recursive: true }, (event) => { const stopWatching = await watch(
"/path/to/something",
(event) => {
const { type, payload } = event; const { type, payload } = event;
}); },
{ recursive: true }
);
const stopRawWatcher = await watchImmediate(['/path/a', '/path/b'], {}, (event) => { const stopRawWatcher = await watchImmediate(
["/path/a", "/path/b"],
(event) => {
const { path, operation, cookie } = event; const { path, operation, cookie } = event;
}); },
{}
);
``` ```
## Contributing ## Contributing

@ -44,8 +44,8 @@ async function unwatch(id: number): Promise<void> {
export async function watch( export async function watch(
paths: string | string[], paths: string | string[],
options: DebouncedWatchOptions, cb: (event: DebouncedEvent) => void,
cb: (event: DebouncedEvent) => void options: DebouncedWatchOptions = {}
): Promise<UnlistenFn> { ): Promise<UnlistenFn> {
const opts = { const opts = {
recursive: false, recursive: false,
@ -82,8 +82,8 @@ export async function watch(
export async function watchImmediate( export async function watchImmediate(
paths: string | string[], paths: string | string[],
options: WatchOptions, cb: (event: RawEvent) => void,
cb: (event: RawEvent) => void options: WatchOptions = {}
): Promise<UnlistenFn> { ): Promise<UnlistenFn> {
const opts = { const opts = {
recursive: false, recursive: false,

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-fs#next pnpm add https://github.com/tauri-apps/tauri-plugin-fs#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-fs#next npm add https://github.com/tauri-apps/tauri-plugin-fs#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-fs#next yarn add https://github.com/tauri-apps/tauri-plugin-fs#v2
``` ```
## Usage ## Usage
@ -51,9 +51,9 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { metadata } from 'tauri-plugin-fs-api'; import { metadata } from "tauri-plugin-fs-api";
await metadata('/path/to/file'); await metadata("/path/to/file");
``` ```
## Contributing ## Contributing

@ -210,14 +210,14 @@ enum BaseDirectory {
Font, Font,
Home, Home,
Runtime, Runtime,
Template Template,
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface FsOptions { interface FsOptions {
dir?: BaseDirectory dir?: BaseDirectory;
// note that adding fields here needs a change in the writeBinaryFile check // note that adding fields here needs a change in the writeBinaryFile check
} }
@ -225,8 +225,8 @@ interface FsOptions {
* @since 1.0.0 * @since 1.0.0
*/ */
interface FsDirOptions { interface FsDirOptions {
dir?: BaseDirectory dir?: BaseDirectory;
recursive?: boolean recursive?: boolean;
} }
/** /**
@ -236,12 +236,12 @@ interface FsDirOptions {
*/ */
interface FsTextFileOption { interface FsTextFileOption {
/** Path to the file to write. */ /** Path to the file to write. */
path: string path: string;
/** The UTF-8 string to write to the file. */ /** The UTF-8 string to write to the file. */
contents: string contents: string;
} }
type BinaryFileContents = Iterable<number> | ArrayLike<number> | ArrayBuffer type BinaryFileContents = Iterable<number> | ArrayLike<number> | ArrayBuffer;
/** /**
* Options object used to write a binary data to a file. * Options object used to write a binary data to a file.
@ -250,23 +250,23 @@ type BinaryFileContents = Iterable<number> | ArrayLike<number> | ArrayBuffer
*/ */
interface FsBinaryFileOption { interface FsBinaryFileOption {
/** Path to the file to write. */ /** Path to the file to write. */
path: string path: string;
/** The byte array contents. */ /** The byte array contents. */
contents: BinaryFileContents contents: BinaryFileContents;
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface FileEntry { interface FileEntry {
path: string path: string;
/** /**
* Name of the directory/file * Name of the directory/file
* can be null if the path terminates with `..` * can be null if the path terminates with `..`
*/ */
name?: string name?: string;
/** Children of this entry if it's a directory; null otherwise */ /** Children of this entry if it's a directory; null otherwise */
children?: FileEntry[] children?: FileEntry[];
} }
/** /**
@ -286,8 +286,8 @@ async function readTextFile(
): Promise<string> { ): Promise<string> {
return await invoke("plugin:fs|read_text_file", { return await invoke("plugin:fs|read_text_file", {
path: filePath, path: filePath,
options options,
}) });
} }
/** /**
@ -307,10 +307,10 @@ async function readBinaryFile(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const arr = await invoke<number[]>("plugin:fs|read_file", { const arr = await invoke<number[]>("plugin:fs|read_file", {
path: filePath, path: filePath,
options options,
}) });
return Uint8Array.from(arr) return Uint8Array.from(arr);
} }
/** /**
@ -328,7 +328,7 @@ async function writeTextFile(
path: string, path: string,
contents: string, contents: string,
options?: FsOptions options?: FsOptions
): Promise<void> ): Promise<void>;
/** /**
* Writes a UTF-8 text file. * Writes a UTF-8 text file.
@ -345,7 +345,7 @@ async function writeTextFile(
async function writeTextFile( async function writeTextFile(
file: FsTextFileOption, file: FsTextFileOption,
options?: FsOptions options?: FsOptions
): Promise<void> ): Promise<void>;
/** /**
* Writes a UTF-8 text file. * Writes a UTF-8 text file.
@ -359,33 +359,33 @@ async function writeTextFile(
contents?: string | FsOptions, contents?: string | FsOptions,
options?: FsOptions options?: FsOptions
): Promise<void> { ): Promise<void> {
if (typeof options === 'object') { if (typeof options === "object") {
Object.freeze(options) Object.freeze(options);
} }
if (typeof path === 'object') { if (typeof path === "object") {
Object.freeze(path) Object.freeze(path);
} }
const file: FsTextFileOption = { path: '', contents: '' } const file: FsTextFileOption = { path: "", contents: "" };
let fileOptions: FsOptions | undefined = options let fileOptions: FsOptions | undefined = options;
if (typeof path === 'string') { if (typeof path === "string") {
file.path = path file.path = path;
} else { } else {
file.path = path.path file.path = path.path;
file.contents = path.contents file.contents = path.contents;
} }
if (typeof contents === 'string') { if (typeof contents === "string") {
file.contents = contents ?? '' file.contents = contents ?? "";
} else { } else {
fileOptions = contents fileOptions = contents;
} }
return await invoke("plugin:fs|write_file", { return await invoke("plugin:fs|write_file", {
path: file.path, path: file.path,
contents: Array.from(new TextEncoder().encode(file.contents)), contents: Array.from(new TextEncoder().encode(file.contents)),
options: fileOptions options: fileOptions,
}) });
} }
/** /**
@ -406,7 +406,7 @@ async function writeBinaryFile(
path: string, path: string,
contents: BinaryFileContents, contents: BinaryFileContents,
options?: FsOptions options?: FsOptions
): Promise<void> ): Promise<void>;
/** /**
* Writes a byte array content to a file. * Writes a byte array content to a file.
@ -426,7 +426,7 @@ async function writeBinaryFile(
async function writeBinaryFile( async function writeBinaryFile(
file: FsBinaryFileOption, file: FsBinaryFileOption,
options?: FsOptions options?: FsOptions
): Promise<void> ): Promise<void>;
/** /**
* Writes a byte array content to a file. * Writes a byte array content to a file.
@ -440,27 +440,27 @@ async function writeBinaryFile(
contents?: BinaryFileContents | FsOptions, contents?: BinaryFileContents | FsOptions,
options?: FsOptions options?: FsOptions
): Promise<void> { ): Promise<void> {
if (typeof options === 'object') { if (typeof options === "object") {
Object.freeze(options) Object.freeze(options);
} }
if (typeof path === 'object') { if (typeof path === "object") {
Object.freeze(path) Object.freeze(path);
} }
const file: FsBinaryFileOption = { path: '', contents: [] } const file: FsBinaryFileOption = { path: "", contents: [] };
let fileOptions: FsOptions | undefined = options let fileOptions: FsOptions | undefined = options;
if (typeof path === 'string') { if (typeof path === "string") {
file.path = path file.path = path;
} else { } else {
file.path = path.path file.path = path.path;
file.contents = path.contents file.contents = path.contents;
} }
if (contents && 'dir' in contents) { if (contents && "dir" in contents) {
fileOptions = contents fileOptions = contents;
} else if (typeof path === 'string') { } else if (typeof path === "string") {
// @ts-expect-error in this case `contents` is always a BinaryFileContents // @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", { return await invoke("plugin:fs|write_binary_file", {
@ -470,8 +470,8 @@ async function writeBinaryFile(
? new Uint8Array(file.contents) ? new Uint8Array(file.contents)
: file.contents : file.contents
), ),
options: fileOptions options: fileOptions,
}) });
} }
/** /**
@ -500,8 +500,8 @@ async function readDir(
): Promise<FileEntry[]> { ): Promise<FileEntry[]> {
return await invoke("plugin:fs|read_dir", { return await invoke("plugin:fs|read_dir", {
path: dir, path: dir,
options options,
}) });
} }
/** /**
@ -525,8 +525,8 @@ async function createDir(
): Promise<void> { ): Promise<void> {
return await invoke("plugin:fs|create_dir", { return await invoke("plugin:fs|create_dir", {
path: dir, path: dir,
options options,
}) });
} }
/** /**
@ -549,8 +549,8 @@ async function removeDir(
): Promise<void> { ): Promise<void> {
return await invoke("plugin:fs|remove_dir", { return await invoke("plugin:fs|remove_dir", {
path: dir, path: dir,
options options,
}) });
} }
/** /**
@ -574,8 +574,8 @@ async function copyFile(
return await invoke("plugin:fs|copy_file", { return await invoke("plugin:fs|copy_file", {
source, source,
destination, destination,
options options,
}) });
} }
/** /**
@ -597,8 +597,8 @@ async function removeFile(
): Promise<void> { ): Promise<void> {
return await invoke("plugin:fs|remove_file", { return await invoke("plugin:fs|remove_file", {
path: file, path: file,
options options,
}) });
} }
/** /**
@ -622,9 +622,8 @@ async function renameFile(
return await invoke("plugin:fs|rename_file", { return await invoke("plugin:fs|rename_file", {
oldPath, oldPath,
newPath, newPath,
options options,
});
})
} }
/** /**
@ -644,7 +643,7 @@ async function exists(path: string): Promise<boolean> {
/** /**
* Returns the metadata for the given path. * Returns the metadata for the given path.
* *
* @since 1.0.0 * @since 1.0.0
*/ */
async function metadata(path: string): Promise<Metadata> { async function metadata(path: string): Promise<Metadata> {
@ -670,7 +669,7 @@ export type {
FileEntry, FileEntry,
Permissions, Permissions,
Metadata, Metadata,
} };
export { export {
BaseDirectory, BaseDirectory,
@ -687,5 +686,5 @@ export {
removeFile, removeFile,
renameFile, renameFile,
exists, exists,
metadata metadata,
} };

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-shortcut = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-shortcut = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-shortcut#next pnpm add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-shortcut#next npm add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-shortcut#next yarn add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
``` ```
## Usage ## Usage

@ -21,9 +21,9 @@
* @module * @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. * Register a global shortcut.
@ -44,10 +44,10 @@ async function register(
shortcut: string, shortcut: string,
handler: ShortcutHandler handler: ShortcutHandler
): Promise<void> { ): Promise<void> {
return await invoke('plugin:globalShortcut|register', { return await invoke("plugin:globalShortcut|register", {
shortcut, shortcut,
handler: transformCallback(handler) handler: transformCallback(handler),
}) });
} }
/** /**
@ -69,15 +69,15 @@ async function registerAll(
shortcuts: string[], shortcuts: string[],
handler: ShortcutHandler handler: ShortcutHandler
): Promise<void> { ): Promise<void> {
return await invoke('plugin:globalShortcut|register_all', { return await invoke("plugin:globalShortcut|register_all", {
shortcuts, shortcuts,
handler: transformCallback(handler) handler: transformCallback(handler),
}) });
} }
/** /**
* Determines whether the given shortcut is registered by this application or not. * 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`. * If the shortcut is registered by another application, it will still return `false`.
* *
* @example * @example
@ -91,9 +91,9 @@ async function registerAll(
* @since 1.0.0 * @since 1.0.0
*/ */
async function isRegistered(shortcut: string): Promise<boolean> { async function isRegistered(shortcut: string): Promise<boolean> {
return await invoke('plugin:globalShortcut|is_registered', { return await invoke("plugin:globalShortcut|is_registered", {
shortcut shortcut,
}) });
} }
/** /**
@ -109,9 +109,9 @@ async function isRegistered(shortcut: string): Promise<boolean> {
* @since 1.0.0 * @since 1.0.0
*/ */
async function unregister(shortcut: string): Promise<void> { async function unregister(shortcut: string): Promise<void> {
return await invoke('plugin:globalShortcut|unregister', { return await invoke("plugin:globalShortcut|unregister", {
shortcut shortcut,
}) });
} }
/** /**
@ -125,7 +125,7 @@ async function unregister(shortcut: string): Promise<void> {
* @since 1.0.0 * @since 1.0.0
*/ */
async function unregisterAll(): Promise<void> { async function unregisterAll(): Promise<void> {
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 };

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-http#next pnpm add https://github.com/tauri-apps/tauri-plugin-http#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-http#next npm add https://github.com/tauri-apps/tauri-plugin-http#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-http#next yarn add https://github.com/tauri-apps/tauri-plugin-http#v2
``` ```
## Usage ## Usage

@ -41,14 +41,14 @@
* @module * @module
*/ */
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from "@tauri-apps/api/tauri";
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface Duration { interface Duration {
secs: number secs: number;
nanos: number nanos: number;
} }
/** /**
@ -59,8 +59,8 @@ interface ClientOptions {
* Defines the maximum number of redirects the client should follow. * Defines the maximum number of redirects the client should follow.
* If set to 0, no redirects will be followed. * If set to 0, no redirects will be followed.
*/ */
maxRedirections?: number maxRedirections?: number;
connectTimeout?: number | Duration connectTimeout?: number | Duration;
} }
/** /**
@ -69,19 +69,19 @@ interface ClientOptions {
enum ResponseType { enum ResponseType {
JSON = 1, JSON = 1,
Text = 2, Text = 2,
Binary = 3 Binary = 3,
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface FilePart<T> { interface FilePart<T> {
file: string | T file: string | T;
mime?: string mime?: string;
fileName?: string fileName?: string;
} }
type Part = string | Uint8Array | FilePart<Uint8Array> type Part = string | Uint8Array | FilePart<Uint8Array>;
/** /**
* The body object to be used on POST and PUT requests. * The body object to be used on POST and PUT requests.
@ -89,13 +89,13 @@ type Part = string | Uint8Array | FilePart<Uint8Array>
* @since 1.0.0 * @since 1.0.0
*/ */
class Body { class Body {
type: string type: string;
payload: unknown payload: unknown;
/** @ignore */ /** @ignore */
private constructor(type: string, payload: unknown) { private constructor(type: string, payload: unknown) {
this.type = type this.type = type;
this.payload = payload this.payload = payload;
} }
/** /**
@ -130,39 +130,39 @@ class Body {
* @returns The body object ready to be used on the POST and PUT requests. * @returns The body object ready to be used on the POST and PUT requests.
*/ */
static form(data: Record<string, Part> | FormData): Body { static form(data: Record<string, Part> | FormData): Body {
const form: Record<string, string | number[] | FilePart<number[]>> = {} const form: Record<string, string | number[] | FilePart<number[]>> = {};
const append = ( const append = (
key: string, key: string,
v: string | Uint8Array | FilePart<Uint8Array> | File v: string | Uint8Array | FilePart<Uint8Array> | File
): void => { ): void => {
if (v !== null) { if (v !== null) {
let r let r;
if (typeof v === 'string') { if (typeof v === "string") {
r = v r = v;
} else if (v instanceof Uint8Array || Array.isArray(v)) { } else if (v instanceof Uint8Array || Array.isArray(v)) {
r = Array.from(v) r = Array.from(v);
} else if (v instanceof File) { } else if (v instanceof File) {
r = { file: v.name, mime: v.type, fileName: v.name } r = { file: v.name, mime: v.type, fileName: v.name };
} else if (typeof v.file === 'string') { } else if (typeof v.file === "string") {
r = { file: v.file, mime: v.mime, fileName: v.fileName } r = { file: v.file, mime: v.mime, fileName: v.fileName };
} else { } 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) { if (data instanceof FormData) {
for (const [key, value] of data) { for (const [key, value] of data) {
append(key, value) append(key, value);
} }
} else { } else {
for (const [key, value] of Object.entries(data)) { 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. * @returns The body object ready to be used on the POST and PUT requests.
*/ */
static json<K extends string | number | symbol, V>(data: Record<K, V>): Body { static json<K extends string | number | symbol, V>(data: Record<K, V>): 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. * @returns The body object ready to be used on the POST and PUT requests.
*/ */
static text(value: string): Body { static text(value: string): Body {
return new Body('Text', value) return new Body("Text", value);
} }
/** /**
@ -217,23 +217,23 @@ class Body {
): Body { ): Body {
// stringifying Uint8Array doesn't return an array of numbers, so we create one here // stringifying Uint8Array doesn't return an array of numbers, so we create one here
return new Body( return new Body(
'Bytes', "Bytes",
Array.from(bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes) Array.from(bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes)
) );
} }
} }
/** The request HTTP verb. */ /** The request HTTP verb. */
type HttpVerb = type HttpVerb =
| 'GET' | "GET"
| 'POST' | "POST"
| 'PUT' | "PUT"
| 'DELETE' | "DELETE"
| 'PATCH' | "PATCH"
| 'HEAD' | "HEAD"
| 'OPTIONS' | "OPTIONS"
| 'CONNECT' | "CONNECT"
| 'TRACE' | "TRACE";
/** /**
* Options object sent to the backend. * Options object sent to the backend.
@ -241,27 +241,27 @@ type HttpVerb =
* @since 1.0.0 * @since 1.0.0
*/ */
interface HttpOptions { interface HttpOptions {
method: HttpVerb method: HttpVerb;
url: string url: string;
headers?: Record<string, unknown> headers?: Record<string, unknown>;
query?: Record<string, unknown> query?: Record<string, unknown>;
body?: Body body?: Body;
timeout?: number | Duration timeout?: number | Duration;
responseType?: ResponseType responseType?: ResponseType;
} }
/** Request options. */ /** Request options. */
type RequestOptions = Omit<HttpOptions, 'method' | 'url'> type RequestOptions = Omit<HttpOptions, "method" | "url">;
/** Options for the `fetch` API. */ /** Options for the `fetch` API. */
type FetchOptions = Omit<HttpOptions, 'url'> type FetchOptions = Omit<HttpOptions, "url">;
/** @ignore */ /** @ignore */
interface IResponse<T> { interface IResponse<T> {
url: string url: string;
status: number status: number;
headers: Record<string, string> headers: Record<string, string>;
rawHeaders: Record<string, string[]> rawHeaders: Record<string, string[]>;
data: T data: T;
} }
/** /**
@ -271,26 +271,26 @@ interface IResponse<T> {
* */ * */
class Response<T> { class Response<T> {
/** The request URL. */ /** The request URL. */
url: string url: string;
/** The response status code. */ /** The response status code. */
status: number status: number;
/** A boolean indicating whether the response was successful (status in the range 200299) or not. */ /** A boolean indicating whether the response was successful (status in the range 200299) or not. */
ok: boolean ok: boolean;
/** The response headers. */ /** The response headers. */
headers: Record<string, string> headers: Record<string, string>;
/** The response raw headers. */ /** The response raw headers. */
rawHeaders: Record<string, string[]> rawHeaders: Record<string, string[]>;
/** The response data. */ /** The response data. */
data: T data: T;
/** @ignore */ /** @ignore */
constructor(response: IResponse<T>) { constructor(response: IResponse<T>) {
this.url = response.url this.url = response.url;
this.status = response.status this.status = response.status;
this.ok = this.status >= 200 && this.status < 300 this.ok = this.status >= 200 && this.status < 300;
this.headers = response.headers this.headers = response.headers;
this.rawHeaders = response.rawHeaders this.rawHeaders = response.rawHeaders;
this.data = response.data this.data = response.data;
} }
} }
@ -298,10 +298,10 @@ class Response<T> {
* @since 1.0.0 * @since 1.0.0
*/ */
class Client { class Client {
id: number id: number;
/** @ignore */ /** @ignore */
constructor(id: number) { constructor(id: number) {
this.id = id this.id = id;
} }
/** /**
@ -314,9 +314,9 @@ class Client {
* ``` * ```
*/ */
async drop(): Promise<void> { async drop(): Promise<void> {
return invoke('plugin:http|drop_client', { return invoke("plugin:http|drop_client", {
client: this.id client: this.id,
}) });
} }
/** /**
@ -333,34 +333,34 @@ class Client {
*/ */
async request<T>(options: HttpOptions): Promise<Response<T>> { async request<T>(options: HttpOptions): Promise<Response<T>> {
const jsonResponse = const jsonResponse =
!options.responseType || options.responseType === ResponseType.JSON !options.responseType || options.responseType === ResponseType.JSON;
if (jsonResponse) { if (jsonResponse) {
options.responseType = ResponseType.Text options.responseType = ResponseType.Text;
} }
return invoke<IResponse<T>>('plugin:http|request', { return invoke<IResponse<T>>("plugin:http|request", {
clientId: this.id, clientId: this.id,
options options,
}).then((res) => { }).then((res) => {
const response = new Response(res) const response = new Response(res);
if (jsonResponse) { if (jsonResponse) {
/* eslint-disable */ /* eslint-disable */
try { try {
response.data = JSON.parse(response.data as string) response.data = JSON.parse(response.data as string);
} catch (e) { } catch (e) {
if (response.ok && (response.data as unknown as string) === '') { if (response.ok && (response.data as unknown as string) === "") {
response.data = {} as T response.data = {} as T;
} else if (response.ok) { } else if (response.ok) {
throw Error( throw Error(
`Failed to parse response \`${response.data}\` as JSON: ${e}; `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.` try setting the \`responseType\` option to \`ResponseType.Text\` or \`ResponseType.Binary\` if the API does not return a JSON response.`
) );
} }
} }
/* eslint-enable */ /* eslint-enable */
return response return response;
} }
return response return response;
}) });
} }
/** /**
@ -378,10 +378,10 @@ class Client {
*/ */
async get<T>(url: string, options?: RequestOptions): Promise<Response<T>> { async get<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({ return this.request({
method: 'GET', method: "GET",
url, url,
...options ...options,
}) });
} }
/** /**
@ -406,11 +406,11 @@ class Client {
options?: RequestOptions options?: RequestOptions
): Promise<Response<T>> { ): Promise<Response<T>> {
return this.request({ return this.request({
method: 'POST', method: "POST",
url, url,
body, body,
...options ...options,
}) });
} }
/** /**
@ -436,11 +436,11 @@ class Client {
options?: RequestOptions options?: RequestOptions
): Promise<Response<T>> { ): Promise<Response<T>> {
return this.request({ return this.request({
method: 'PUT', method: "PUT",
url, url,
body, body,
...options ...options,
}) });
} }
/** /**
@ -456,10 +456,10 @@ class Client {
*/ */
async patch<T>(url: string, options?: RequestOptions): Promise<Response<T>> { async patch<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({ return this.request({
method: 'PATCH', method: "PATCH",
url, url,
...options ...options,
}) });
} }
/** /**
@ -473,10 +473,10 @@ class Client {
*/ */
async delete<T>(url: string, options?: RequestOptions): Promise<Response<T>> { async delete<T>(url: string, options?: RequestOptions): Promise<Response<T>> {
return this.request({ return this.request({
method: 'DELETE', method: "DELETE",
url, url,
...options ...options,
}) });
} }
} }
@ -495,13 +495,13 @@ class Client {
* @since 1.0.0 * @since 1.0.0
*/ */
async function getClient(options?: ClientOptions): Promise<Client> { async function getClient(options?: ClientOptions): Promise<Client> {
return invoke<number>('plugin:http|create_client', { return invoke<number>("plugin:http|create_client", {
options options,
}).then((id) => new Client(id)) }).then((id) => new Client(id));
} }
/** @internal */ /** @internal */
let defaultClient: Client | null = null let defaultClient: Client | null = null;
/** /**
* Perform an HTTP request using the default client. * Perform an HTTP request using the default client.
@ -519,13 +519,13 @@ async function fetch<T>(
options?: FetchOptions options?: FetchOptions
): Promise<Response<T>> { ): Promise<Response<T>> {
if (defaultClient === null) { if (defaultClient === null) {
defaultClient = await getClient() defaultClient = await getClient();
} }
return defaultClient.request({ return defaultClient.request({
url, url,
method: options?.method ?? 'GET', method: options?.method ?? "GET",
...options ...options,
}) });
} }
export type { export type {
@ -535,7 +535,15 @@ export type {
HttpVerb, HttpVerb,
HttpOptions, HttpOptions,
RequestOptions, RequestOptions,
FetchOptions FetchOptions,
} };
export { getClient, fetch, Body, Client, Response, ResponseType, type FilePart } export {
getClient,
fetch,
Body,
Client,
Response,
ResponseType,
type FilePart,
};

@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
portpicker = "0.1" # used in the example to pick a random free port portpicker = "0.1" # used in the example to pick a random free port
``` ```

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-log#next pnpm add https://github.com/tauri-apps/tauri-plugin-log#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-log#next npm add https://github.com/tauri-apps/tauri-plugin-log#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-log#next yarn add https://github.com/tauri-apps/tauri-plugin-log#v2
``` ```
## Usage ## Usage
@ -57,14 +57,14 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { trace, info, error, attachConsole } from 'tauri-plugin-log-api'; import { trace, info, error, attachConsole } from "tauri-plugin-log-api";
// with LogTarget::Webview enabled this function will print logs to the browser console // with LogTarget::Webview enabled this function will print logs to the browser console
const detach = await attachConsole(); const detach = await attachConsole();
trace('Trace'); trace("Trace");
info('Info'); info("Info");
error('Error'); error("Error");
// detach the browser console from the log stream // detach the browser console from the log stream
detach(); detach();

@ -52,10 +52,15 @@ async function log(
const { file, line, ...keyValues } = options ?? {}; 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", { await invoke("plugin:log|log", {
level, level,
message, message,
location: filtered?.[0]?.filter((v) => v.length > 0).join("@"), location,
file, file,
line, line,
keyValues, keyValues,
@ -184,7 +189,8 @@ export async function attachConsole(): Promise<UnlistenFn> {
// Strip ANSI escape codes // Strip ANSI escape codes
const message = payload.message.replace( 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, /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
"" ""
); );

@ -178,8 +178,8 @@ fn log(
let location = location.unwrap_or("webview"); let location = location.unwrap_or("webview");
let mut builder = RecordBuilder::new(); let mut builder = RecordBuilder::new();
builder builder
.target(location)
.level(level.into()) .level(level.into())
.target(location)
.file(file) .file(file)
.line(line); .line(line);
@ -251,8 +251,8 @@ impl Builder {
out.finish(format_args!( out.finish(format_args!(
"{}[{}][{}] {}", "{}[{}][{}] {}",
timezone_strategy.get_now().format(&format).unwrap(), timezone_strategy.get_now().format(&format).unwrap(),
record.target(),
record.level(), record.level(),
record.target(),
message message
)) ))
}); });
@ -311,8 +311,8 @@ impl Builder {
out.finish(format_args!( out.finish(format_args!(
"{}[{}][{}] {}", "{}[{}][{}] {}",
timezone_strategy.get_now().format(&format).unwrap(), timezone_strategy.get_now().format(&format).unwrap(),
record.target(),
colors.color(record.level()), colors.color(record.level()),
record.target(),
message message
)) ))
}) })

@ -16,6 +16,10 @@ serde_json.workspace = true
tauri.workspace = true tauri.workspace = true
log.workspace = true log.workspace = true
thiserror.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] [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" notify-rust = "4.5"

@ -5,11 +5,11 @@ plugins {
android { android {
namespace = "app.tauri.notification" namespace = "app.tauri.notification"
compileSdk = 32 compileSdk = 33
defaultConfig { defaultConfig {
minSdk = 24 minSdk = 24
targetSdk = 32 targetSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")

@ -1,3 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> package="app.tauri.notification">
<application>
<receiver android:name="app.tauri.notification.TimedNotificationPublisher" />
<receiver android:name="app.tauri.notification.NotificationDismissReceiver" />
<receiver
android:name="app.tauri.notification.NotificationRestoreReceiver"
android:directBootAware="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
</manifest> </manifest>

@ -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
}
}
}

@ -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<NotificationChannel> =
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")
}
}
}

@ -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<String>? = null
var isGroupSummary = false
var isOngoing = false
var isAutoCancel = false
var extra: JSObject? = null
var attachments: List<NotificationAttachment>? = 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<String> = ArrayList()
for (i in 0 until inboxLines.length()) {
inboxStringList.add(inboxLines.getString(i))
}
notification.inboxLines = inboxStringList
} catch (_: Exception) {
}
return notification
}
fun buildNotificationPendingList(notifications: List<Notification>): 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
}
}
}

@ -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<String, List<NotificationAction>> {
val actionTypeMap: MutableMap<String, List<NotificationAction>> = HashMap()
try {
val objects: List<JSONObject> = types.toList()
for (obj in objects) {
val jsObject = JSObject.fromJSONObject(
obj
)
val actionGroupId = jsObject.getString("id")
val actions = jsObject.getJSONArray("actions")
val typesArray = mutableListOf<NotificationAction>()
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
}
}
}

@ -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<NotificationAttachment> {
val attachmentsList: MutableList<NotificationAttachment> = 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
}
}
}

@ -1,31 +1,263 @@
package app.tauri.notification package app.tauri.notification
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity 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.Command
import app.tauri.annotation.Permission
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin 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) { class NotificationPlugin(private val activity: Activity): Plugin(activity) {
@Command private var webView: WebView? = null
fun requestPermission(invoke: Invoke) { private lateinit var manager: TauriNotificationManager
val ret = JSObject() private lateinit var notificationManager: NotificationManager
ret.put("permissionState", "granted") private lateinit var notificationStorage: NotificationStorage
invoke.resolve(ret) 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<Notification> =
ArrayList(notificationArray.length())
val notificationsInput: List<JSONObject> = 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 val ids = manager.schedule(notifications)
fun permissionState(invoke: Invoke) { notificationStorage.appendNotifications(notifications)
val ret = JSObject()
ret.put("permissionState", "granted") val result = JSObject()
invoke.resolve(ret) result.put("notifications", ids)
invoke.resolve(result)
}
@Command
fun cancel(invoke: Invoke) {
val notifications: List<Int> = 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<Any>()) {
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 private fun getPermissionState(): String {
fun notify(invoke: Invoke) { return if (manager.areNotificationsEnabled()) {
// TODO "granted"
invoke.resolve() } else {
"denied"
} }
}
} }

@ -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
}
}
}
}

@ -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<Notification>) {
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<String> {
val storage = getStorage(NOTIFICATION_STORE_ID)
val all = storage.all
return if (all != null) {
ArrayList(all.keys)
} else ArrayList()
}
fun getSavedNotifications(): List<Notification> {
val storage = getStorage(NOTIFICATION_STORE_ID)
val all = storage.all
if (all != null) {
val notifications = ArrayList<Notification>()
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<String, List<NotificationAction>>) {
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<NotificationAction?> {
val storage = getStorage(ACTION_TYPES_ID + forId)
val count = storage.getInt("count", 0)
val actions: Array<NotificationAction?> = 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
}
}

@ -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<Notification>): List<Int> {
val ids = mutableListOf<Int>()
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<Int>) {
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<Notification>()
val updatedNotifications = mutableListOf<Notification>()
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)
}
}

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:width="1dp"
android:color="@android:color/transparent" />
</vector>

@ -24,7 +24,7 @@
* @module * @module
*/ */
import { invoke } from '@tauri-apps/api/tauri' import { invoke, transformCallback } from '@tauri-apps/api/tauri'
/** /**
* Options to send a notification. * Options to send a notification.
@ -32,16 +32,273 @@ import { invoke } from '@tauri-apps/api/tauri'
* @since 1.0.0 * @since 1.0.0
*/ */
interface Options { interface Options {
/** Notification title. */ /**
* 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 title: string
/** Optional notification body. */ /**
* Optional notification body.
* */
body?: string body?: string
/** Optional notification icon. */ /**
* 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 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<string, string>
extra: Record<string, unknown>
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. */ /** Possible permission values. */
type Permission = 'granted' | 'denied' | 'default' type Permission = "granted" | "denied" | "default";
/** /**
* Checks if the permission to send notifications is granted. * Checks if the permission to send notifications is granted.
@ -54,10 +311,10 @@ type Permission = 'granted' | 'denied' | 'default'
* @since 1.0.0 * @since 1.0.0
*/ */
async function isPermissionGranted(): Promise<boolean> { async function isPermissionGranted(): Promise<boolean> {
if (window.Notification.permission !== 'default') { if (window.Notification.permission !== "default") {
return Promise.resolve(window.Notification.permission === 'granted') return Promise.resolve(window.Notification.permission === "granted");
} }
return invoke('plugin:notification|is_permission_granted') return invoke("plugin:notification|is_permission_granted");
} }
/** /**
@ -77,7 +334,7 @@ async function isPermissionGranted(): Promise<boolean> {
* @since 1.0.0 * @since 1.0.0
*/ */
async function requestPermission(): Promise<Permission> { async function requestPermission(): Promise<Permission> {
return window.Notification.requestPermission() return window.Notification.requestPermission();
} }
/** /**
@ -99,15 +356,250 @@ async function requestPermission(): Promise<Permission> {
* @since 1.0.0 * @since 1.0.0
*/ */
function sendNotification(options: Options | string): void { function sendNotification(options: Options | string): void {
if (typeof options === 'string') { if (typeof options === "string") {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new window.Notification(options) new window.Notification(options);
} else { } else {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new window.Notification(options.title, options) new window.Notification(options.title, options);
}
}
/**
* 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<void> {
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<PendingNotification[]> {
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<void> {
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<void> {
return invoke('plugin:notification|cancel')
}
/**
* 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<ActiveNotification[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Channel[]> {
return invoke('plugin:notification|getActive')
}
class EventChannel {
id: number
unregisterFn: (channel: EventChannel) => Promise<void>
constructor(id: number, unregisterFn: (channel: EventChannel) => Promise<void>) {
this.id = id
this.unregisterFn = unregisterFn
}
toJSON(): string {
return `__CHANNEL__:${this.id}`
}
async unregister(): Promise<void> {
return this.unregisterFn(this)
} }
} }
export type { Options, Permission } // TODO: use addPluginListener API on @tauri-apps/api/tauri 2.0.0-alpha.4
async function onNotificationReceived(cb: (notification: Options) => void): Promise<EventChannel> {
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<EventChannel> {
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 { sendNotification, requestPermission, isPermissionGranted }
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
}

@ -4,16 +4,16 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "tauri-plugin-{{ plugin_name }}", name: "tauri-plugin-notification",
platforms: [ platforms: [
.iOS(.v13), .iOS(.v13),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
.library( .library(
name: "tauri-plugin-{{ plugin_name }}", name: "tauri-plugin-notification",
type: .static, type: .static,
targets: ["tauri-plugin-{{ plugin_name }}"]), targets: ["tauri-plugin-notification"]),
], ],
dependencies: [ dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api") .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 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. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "tauri-plugin-{{ plugin_name }}", name: "tauri-plugin-notification",
dependencies: [ dependencies: [
.byName(name: "Tauri") .byName(name: "Tauri")
], ],

@ -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
}
}

@ -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)
}

@ -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
}
}

@ -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()
}
}

@ -1,24 +1,209 @@
import SwiftRs
import Tauri
import UIKit import UIKit
import UserNotifications
import WebKit 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 { class NotificationPlugin: Plugin {
@objc public func requestPermission(_ invoke: Invoke) throws { let notificationHandler = NotificationHandler()
invoke.resolve(["permissionState": "granted"]) let notificationManager = NotificationManager()
}
override init() {
@objc public func permissionState(_ invoke: Invoke) throws { super.init()
invoke.resolve(["permissionState": "granted"]) notificationManager.notificationHandler = notificationHandler
} notificationHandler.plugin = self
}
@objc public func notify(_ invoke: Invoke) throws {
// TODO @objc public func show(_ invoke: Invoke) throws {
invoke.resolve() 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") @_cdecl("init_plugin_notification")
func initPlugin(name: SRString, webview: WKWebView?) { func initPlugin() -> Plugin {
Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin()) return NotificationPlugin()
} }

@ -2,30 +2,21 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use serde::Deserialize;
use tauri::{command, AppHandle, Runtime, State}; use tauri::{command, AppHandle, Runtime, State};
use crate::{Notification, PermissionState, Result}; use crate::{Notification, NotificationData, 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<String>,
/// The notification icon.
pub icon: Option<String>,
}
#[command] #[command]
pub(crate) async fn is_permission_granted<R: Runtime>( pub(crate) async fn is_permission_granted<R: Runtime>(
_app: AppHandle<R>, _app: AppHandle<R>,
notification: State<'_, Notification<R>>, notification: State<'_, Notification<R>>,
) -> Result<bool> { ) -> Result<Option<bool>> {
notification let state = notification.permission_state()?;
.permission_state() match state {
.map(|s| s == PermissionState::Granted) PermissionState::Granted => Ok(Some(true)),
PermissionState::Denied => Ok(Some(false)),
PermissionState::Unknown => Ok(None),
}
} }
#[command] #[command]
@ -40,15 +31,9 @@ pub(crate) async fn request_permission<R: Runtime>(
pub(crate) async fn notify<R: Runtime>( pub(crate) async fn notify<R: Runtime>(
_app: AppHandle<R>, _app: AppHandle<R>,
notification: State<'_, Notification<R>>, notification: State<'_, Notification<R>>,
options: NotificationOptions, options: NotificationData,
) -> Result<()> { ) -> Result<()> {
let mut builder = notification.builder().title(options.title); let mut builder = notification.builder();
if let Some(body) = options.body { builder.data = options;
builder = builder.body(body);
}
if let Some(icon) = options.icon {
builder = builder.icon(icon);
}
builder.show() builder.show()
} }

@ -1,71 +1,71 @@
(function () { (function () {
let permissionSettable = false let permissionSettable = false;
let permissionValue = 'default' let permissionValue = "default";
function isPermissionGranted() { function isPermissionGranted() {
if (window.Notification.permission !== 'default') { if (window.Notification.permission !== "default") {
return Promise.resolve(window.Notification.permission === 'granted') 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) { function setNotificationPermission(value) {
permissionSettable = true permissionSettable = true;
// @ts-expect-error we can actually set this value on the webview // @ts-expect-error we can actually set this value on the webview
window.Notification.permission = value window.Notification.permission = value;
permissionSettable = false permissionSettable = false;
} }
function requestPermission() { function requestPermission() {
return __TAURI__.invoke('plugin:notification|request_permission') return window.__TAURI__
.invoke("plugin:notification|request_permission")
.then(function (permission) { .then(function (permission) {
setNotificationPermission(permission) setNotificationPermission(permission);
return permission return permission;
}) });
} }
function sendNotification(options) { function sendNotification(options) {
if (typeof options === 'object') { if (typeof options === "object") {
Object.freeze(options) Object.freeze(options);
} }
return __TAURI__.invoke('plugin:notification|notify', { return window.__TAURI__.invoke("plugin:notification|notify", {
options: typeof options === 'string' options:
? { typeof options === "string"
title: options ? {
} title: options,
: options }
}) : options,
});
} }
// @ts-expect-error unfortunately we can't implement the whole type, so we overwrite it with our own version // @ts-expect-error unfortunately we can't implement the whole type, so we overwrite it with our own version
window.Notification = function (title, options) { window.Notification = function (title, options) {
const opts = options || {} const opts = options || {};
sendNotification( sendNotification(Object.assign(opts, { title }));
Object.assign(opts, { title }) };
)
}
window.Notification.requestPermission = requestPermission window.Notification.requestPermission = requestPermission;
Object.defineProperty(window.Notification, 'permission', { Object.defineProperty(window.Notification, "permission", {
enumerable: true, enumerable: true,
get: function () { get: function () {
return permissionValue return permissionValue;
}, },
set: function (v) { set: function (v) {
if (!permissionSettable) { if (!permissionSettable) {
throw new Error('Readonly property') throw new Error("Readonly property");
} }
permissionValue = v permissionValue = v;
} },
}) });
isPermissionGranted().then(function (response) { isPermissionGranted().then(function (response) {
if (response === null) { if (response === null) {
setNotificationPermission('default') setNotificationPermission("default");
} else { } else {
setNotificationPermission(response ? 'granted' : 'denied') setNotificationPermission(response ? "granted" : "denied");
} }
}) });
})() })();

@ -30,16 +30,6 @@ use desktop::Notification;
#[cfg(mobile)] #[cfg(mobile)]
use mobile::Notification; use mobile::Notification;
#[derive(Debug, Default, Serialize)]
struct NotificationData {
/// The notification title.
title: Option<String>,
/// The notification body.
body: Option<String>,
/// The notification icon.
icon: Option<String>,
}
/// The notification builder. /// The notification builder.
#[derive(Debug)] #[derive(Debug)]
pub struct NotificationBuilder<R: Runtime> { pub struct NotificationBuilder<R: Runtime> {
@ -47,7 +37,7 @@ pub struct NotificationBuilder<R: Runtime> {
app: AppHandle<R>, app: AppHandle<R>,
#[cfg(mobile)] #[cfg(mobile)]
handle: PluginHandle<R>, handle: PluginHandle<R>,
data: NotificationData, pub(crate) data: NotificationData,
} }
impl<R: Runtime> NotificationBuilder<R> { impl<R: Runtime> NotificationBuilder<R> {
@ -67,6 +57,21 @@ impl<R: Runtime> NotificationBuilder<R> {
} }
} }
/// 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<String>) -> Self {
self.data.channel_id.replace(id.into());
self
}
/// Sets the notification title. /// Sets the notification title.
pub fn title(mut self, title: impl Into<String>) -> Self { pub fn title(mut self, title: impl Into<String>) -> Self {
self.data.title.replace(title.into()); self.data.title.replace(title.into());
@ -79,11 +84,119 @@ impl<R: Runtime> NotificationBuilder<R> {
self 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> Self { pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.data.icon.replace(icon.into()); self.data.icon.replace(icon.into());
self 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<String>) -> Self {
self.data.large_icon.replace(large_icon.into());
self
}
/// Icon color on Android.
pub fn icon_color(mut self, icon_color: impl Into<String>) -> 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<String>, 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. /// Extensions to [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`] to access the notification APIs.

@ -10,6 +10,8 @@ use tauri::{
use crate::models::*; use crate::models::*;
use std::collections::HashMap;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; const PLUGIN_IDENTIFIER: &str = "app.tauri.notification";
@ -31,7 +33,8 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
impl<R: Runtime> crate::NotificationBuilder<R> { impl<R: Runtime> crate::NotificationBuilder<R> {
pub fn show(self) -> crate::Result<()> { pub fn show(self) -> crate::Result<()> {
self.handle self.handle
.run_mobile_plugin("notify", self.data) .run_mobile_plugin::<ShowResponse>("show", self.data)
.map(|_| ())
.map_err(Into::into) .map_err(Into::into)
} }
} }
@ -46,17 +49,121 @@ impl<R: Runtime> Notification<R> {
pub fn request_permission(&self) -> crate::Result<PermissionState> { pub fn request_permission(&self) -> crate::Result<PermissionState> {
self.0 self.0
.run_mobile_plugin::<PermissionResponse>("requestPermission", ()) .run_mobile_plugin::<PermissionResponse>("requestPermissions", ())
.map(|r| r.permission_state) .map(|r| r.permission_state)
.map_err(Into::into) .map_err(Into::into)
} }
pub fn permission_state(&self) -> crate::Result<PermissionState> { pub fn permission_state(&self) -> crate::Result<PermissionState> {
self.0 self.0
.run_mobile_plugin::<PermissionResponse>("permissionState", ()) .run_mobile_plugin::<PermissionResponse>("checkPermissions", ())
.map(|r| r.permission_state) .map(|r| r.permission_state)
.map_err(Into::into) .map_err(Into::into)
} }
pub fn register_action_types(&self, types: Vec<ActionType>) -> 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<i32>) -> 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::<Vec<HashMap<&str, i32>>>(),
);
self.0
.run_mobile_plugin("removeActive", args)
.map_err(Into::into)
}
pub fn active(&self) -> crate::Result<Vec<ActiveNotification>> {
self.0
.run_mobile_plugin::<ActiveResponse>("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<Vec<PendingNotification>> {
self.0
.run_mobile_plugin::<PendingResponse>("getPending", ())
.map(|r| r.notifications)
.map_err(Into::into)
}
/// Cancel pending notifications.
pub fn cancel(&self, notifications: Vec<i32>) -> 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<String>) -> 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<Vec<Channel>> {
self.0
.run_mobile_plugin::<ListChannelsResult>("listChannels", ())
.map(|r| r.channels)
.map_err(Into::into)
}
}
#[cfg(target_os = "android")]
#[derive(Deserialize)]
struct ListChannelsResult {
channels: Vec<Channel>,
}
#[derive(Deserialize)]
struct PendingResponse {
notifications: Vec<PendingNotification>,
}
#[derive(Deserialize)]
struct ActiveResponse {
notifications: Vec<ActiveNotification>,
}
#[derive(Deserialize)]
struct ShowResponse {
#[allow(dead_code)]
id: i32,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

@ -2,10 +2,201 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT // 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 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<String>, url: Url) -> Self {
Self { id: id.into(), url }
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleInterval {
pub year: Option<u8>,
pub month: Option<u8>,
pub day: Option<u8>,
pub weekday: Option<u8>,
pub hour: Option<u8>,
pub minute: Option<u8>,
pub second: Option<u8>,
}
#[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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
impl<'de> Deserialize<'de> for ScheduleEvery {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
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<S: Serializer>(
datetime: &OffsetDateTime,
serializer: S,
) -> Result<S::Ok, S::Error> {
datetime
.format(&Iso8601::<SERDE_CONFIG>)
.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<String>,
pub(crate) title: Option<String>,
pub(crate) body: Option<String>,
pub(crate) schedule: Option<Schedule>,
pub(crate) large_body: Option<String>,
pub(crate) summary: Option<String>,
pub(crate) action_type_id: Option<String>,
pub(crate) group: Option<String>,
#[serde(default)]
pub(crate) group_summary: bool,
pub(crate) sound: Option<String>,
#[serde(default)]
pub(crate) inbox_lines: Vec<String>,
pub(crate) icon: Option<String>,
pub(crate) large_icon: Option<String>,
pub(crate) icon_color: Option<String>,
#[serde(default)]
pub(crate) attachments: Vec<Attachment>,
#[serde(default)]
pub(crate) extra: HashMap<String, serde_json::Value>,
#[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. /// Permission state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionState { pub enum PermissionState {
@ -13,6 +204,8 @@ pub enum PermissionState {
Granted, Granted,
/// Permission access has been denied. /// Permission access has been denied.
Denied, Denied,
/// Unknown state. Must request permission.
Unknown,
} }
impl Display for PermissionState { impl Display for PermissionState {
@ -20,6 +213,7 @@ impl Display for PermissionState {
match self { match self {
Self::Granted => write!(f, "granted"), Self::Granted => write!(f, "granted"),
Self::Denied => write!(f, "denied"), 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() { match s.to_lowercase().as_str() {
"granted" => Ok(Self::Granted), "granted" => Ok(Self::Granted),
"denied" => Ok(Self::Denied), "denied" => Ok(Self::Denied),
"default" => Ok(Self::Unknown),
_ => Err(DeError::custom(format!("unknown permission state '{s}'"))), _ => Err(DeError::custom(format!("unknown permission state '{s}'"))),
} }
} }
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingNotification {
id: i32,
title: Option<String>,
body: Option<String>,
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<String>,
title: Option<String>,
body: Option<String>,
group: Option<String>,
#[serde(default)]
group_summary: bool,
#[serde(default)]
data: HashMap<String, String>,
#[serde(default)]
extra: HashMap<String, serde_json::Value>,
#[serde(default)]
attachments: Vec<Attachment>,
action_type_id: Option<String>,
schedule: Option<Schedule>,
sound: Option<String>,
}
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<String, String> {
&self.data
}
pub fn extra(&self) -> &HashMap<String, serde_json::Value> {
&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<Action>,
hidden_previews_body_placeholder: Option<String>,
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<String>,
input_placeholder: Option<String>,
}
#[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<String>,
sound: Option<String>,
lights: bool,
light_color: Option<String>,
vibration: bool,
importance: Importance,
visibility: Option<Visibility>,
}
#[derive(Debug)]
pub struct ChannelBuilder(Channel);
impl Channel {
pub fn builder(id: impl Into<String>, name: impl Into<String>) -> 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<Visibility> {
self.visibility
}
}
impl ChannelBuilder {
pub fn description(mut self, description: impl Into<String>) -> Self {
self.0.description.replace(description.into());
self
}
pub fn sound(mut self, sound: impl Into<String>) -> 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<String>) -> 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
}
}
}

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-persisted-scope = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
## Usage ## Usage

@ -22,7 +22,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
[dependencies] [dependencies]
tauri-plugin-positioner = "1.0" tauri-plugin-positioner = "1.0"
# or through git # or through git
tauri-plugin-positioner = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-positioner = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -30,21 +30,21 @@ 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. > 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 ```sh
pnpm add tauri-plugin-positioner pnpm add tauri-plugin-positioner-api
# or # or
npm add tauri-plugin-positioner npm add tauri-plugin-positioner-api
# or # or
yarn add tauri-plugin-positioner yarn add tauri-plugin-positioner-api
``` ```
Or through git: Or through git:
```sh ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-positioner#next pnpm add https://github.com/tauri-apps/tauri-plugin-positioner#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-positioner#next npm add https://github.com/tauri-apps/tauri-plugin-positioner#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-positioner#next yarn add https://github.com/tauri-apps/tauri-plugin-positioner#v2
``` ```
## Usage ## Usage
@ -69,7 +69,7 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { move_window, Position } from 'tauri-plugin-positioner-api'; import { move_window, Position } from "tauri-plugin-positioner-api";
move_window(Position.TopRight); move_window(Position.TopRight);
``` ```

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-shell#next pnpm add https://github.com/tauri-apps/tauri-plugin-shell#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-shell#next npm add https://github.com/tauri-apps/tauri-plugin-shell#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-shell#next yarn add https://github.com/tauri-apps/tauri-plugin-shell#v2
``` ```
## Usage ## Usage

@ -75,27 +75,27 @@
* @module * @module
*/ */
import { invoke, transformCallback } from '@tauri-apps/api/tauri' import { invoke, transformCallback } from "@tauri-apps/api/tauri";
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
interface SpawnOptions { interface SpawnOptions {
/** Current working directory. */ /** Current working directory. */
cwd?: string cwd?: string;
/** Environment variables. set to `null` to clear the process env. */ /** Environment variables. set to `null` to clear the process env. */
env?: Record<string, string> env?: Record<string, string>;
/** /**
* Character encoding for stdout/stderr * Character encoding for stdout/stderr
* *
* @since 1.1.0 * @since 1.1.0
* */ * */
encoding?: string encoding?: string;
} }
/** @ignore */ /** @ignore */
interface InternalSpawnOptions extends SpawnOptions { interface InternalSpawnOptions extends SpawnOptions {
sidecar?: boolean sidecar?: boolean;
} }
/** /**
@ -103,13 +103,13 @@ interface InternalSpawnOptions extends SpawnOptions {
*/ */
interface ChildProcess<O extends IOPayload> { interface ChildProcess<O extends IOPayload> {
/** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ /** 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. */ /** 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`. */ /** The data that the process wrote to `stdout`. */
stdout: O stdout: O;
/** The data that the process wrote to `stderr`. */ /** The data that the process wrote to `stderr`. */
stderr: O stderr: O;
} }
/** /**
@ -128,26 +128,27 @@ async function execute<O extends IOPayload>(
args: string | string[] = [], args: string | string[] = [],
options?: InternalSpawnOptions options?: InternalSpawnOptions
): Promise<number> { ): Promise<number> {
if (typeof args === 'object') { if (typeof args === "object") {
Object.freeze(args) Object.freeze(args);
} }
return invoke<number>('plugin:shell|execute', { return invoke<number>("plugin:shell|execute", {
program, program,
args, args,
options, options,
onEventFn: transformCallback(onEvent) onEventFn: transformCallback(onEvent),
}) });
} }
/** /**
* @since 1.0.0 * @since 1.0.0
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class EventEmitter<E extends Record<string, any>> { class EventEmitter<E extends Record<string, any>> {
/** @ignore */ /** @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<keyof E, Array<(arg: any) => void>> = private eventListeners: Record<keyof E, Array<(arg: any) => void>> =
Object.create(null) Object.create(null);
/** /**
* Alias for `emitter.on(eventName, listener)`. * Alias for `emitter.on(eventName, listener)`.
@ -158,7 +159,7 @@ class EventEmitter<E extends Record<string, any>> {
eventName: N, eventName: N,
listener: (arg: E[typeof eventName]) => void listener: (arg: E[typeof eventName]) => void
): this { ): this {
return this.on(eventName, listener) return this.on(eventName, listener);
} }
/** /**
@ -170,7 +171,7 @@ class EventEmitter<E extends Record<string, any>> {
eventName: N, eventName: N,
listener: (arg: E[typeof eventName]) => void listener: (arg: E[typeof eventName]) => void
): this { ): this {
return this.off(eventName, listener) return this.off(eventName, listener);
} }
/** /**
@ -189,12 +190,12 @@ class EventEmitter<E extends Record<string, any>> {
): this { ): this {
if (eventName in this.eventListeners) { if (eventName in this.eventListeners) {
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
this.eventListeners[eventName].push(listener) this.eventListeners[eventName].push(listener);
} else { } else {
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
this.eventListeners[eventName] = [listener] this.eventListeners[eventName] = [listener];
} }
return this return this;
} }
/** /**
@ -210,11 +211,11 @@ class EventEmitter<E extends Record<string, any>> {
listener: (arg: E[typeof eventName]) => void listener: (arg: E[typeof eventName]) => void
): this { ): this {
const wrapper = (arg: E[typeof eventName]): void => { const wrapper = (arg: E[typeof eventName]): void => {
this.removeListener(eventName, wrapper) this.removeListener(eventName, wrapper);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
listener(arg) listener(arg);
} };
return this.addListener(eventName, wrapper) return this.addListener(eventName, wrapper);
} }
/** /**
@ -231,9 +232,9 @@ class EventEmitter<E extends Record<string, any>> {
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
this.eventListeners[eventName] = this.eventListeners[eventName].filter( this.eventListeners[eventName] = this.eventListeners[eventName].filter(
(l) => l !== listener (l) => l !== listener
) );
} }
return this return this;
} }
/** /**
@ -246,12 +247,12 @@ class EventEmitter<E extends Record<string, any>> {
removeAllListeners<N extends keyof E>(event?: N): this { removeAllListeners<N extends keyof E>(event?: N): this {
if (event) { if (event) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete,security/detect-object-injection // eslint-disable-next-line @typescript-eslint/no-dynamic-delete,security/detect-object-injection
delete this.eventListeners[event] delete this.eventListeners[event];
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // 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 +265,12 @@ class EventEmitter<E extends Record<string, any>> {
emit<N extends keyof E>(eventName: N, arg: E[typeof eventName]): boolean { emit<N extends keyof E>(eventName: N, arg: E[typeof eventName]): boolean {
if (eventName in this.eventListeners) { if (eventName in this.eventListeners) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,security/detect-object-injection // 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
for (const listener of listeners) listener(arg) for (const listener of listeners) listener(arg);
return true return true;
} }
return false return false;
} }
/** /**
@ -280,8 +281,8 @@ class EventEmitter<E extends Record<string, any>> {
listenerCount<N extends keyof E>(eventName: N): number { listenerCount<N extends keyof E>(eventName: N): number {
if (eventName in this.eventListeners) if (eventName in this.eventListeners)
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
return this.eventListeners[eventName].length return this.eventListeners[eventName].length;
return 0 return 0;
} }
/** /**
@ -300,12 +301,12 @@ class EventEmitter<E extends Record<string, any>> {
): this { ): this {
if (eventName in this.eventListeners) { if (eventName in this.eventListeners) {
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
this.eventListeners[eventName].unshift(listener) this.eventListeners[eventName].unshift(listener);
} else { } else {
// eslint-disable-next-line security/detect-object-injection // eslint-disable-next-line security/detect-object-injection
this.eventListeners[eventName] = [listener] this.eventListeners[eventName] = [listener];
} }
return this return this;
} }
/** /**
@ -320,12 +321,13 @@ class EventEmitter<E extends Record<string, any>> {
eventName: N, eventName: N,
listener: (arg: E[typeof eventName]) => void listener: (arg: E[typeof eventName]) => void
): this { ): this {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapper = (arg: any): void => { const wrapper = (arg: any): void => {
this.removeListener(eventName, wrapper) this.removeListener(eventName, wrapper);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
listener(arg) listener(arg);
} };
return this.prependListener(eventName, wrapper) return this.prependListener(eventName, wrapper);
} }
} }
@ -334,10 +336,10 @@ class EventEmitter<E extends Record<string, any>> {
*/ */
class Child { class Child {
/** The child process `pid`. */ /** The child process `pid`. */
pid: number pid: number;
constructor(pid: number) { constructor(pid: number) {
this.pid = pid this.pid = pid;
} }
/** /**
@ -356,11 +358,11 @@ class Child {
* @returns A promise indicating the success or failure of the operation. * @returns A promise indicating the success or failure of the operation.
*/ */
async write(data: IOPayload): Promise<void> { async write(data: IOPayload): Promise<void> {
return invoke('plugin:shell|stdin_write', { return invoke("plugin:shell|stdin_write", {
pid: this.pid, pid: this.pid,
// correctly serialize Uint8Arrays // correctly serialize Uint8Arrays
buffer: typeof data === 'string' ? data : Array.from(data) buffer: typeof data === "string" ? data : Array.from(data),
}) });
} }
/** /**
@ -369,20 +371,20 @@ class Child {
* @returns A promise indicating the success or failure of the operation. * @returns A promise indicating the success or failure of the operation.
*/ */
async kill(): Promise<void> { async kill(): Promise<void> {
return invoke('plugin:shell|kill', { return invoke("plugin:shell|kill", {
cmd: 'killChild', cmd: "killChild",
pid: this.pid pid: this.pid,
}) });
} }
} }
interface CommandEvents { interface CommandEvents {
close: TerminatedPayload close: TerminatedPayload;
error: string error: string;
} }
interface OutputEvents<O extends IOPayload> { interface OutputEvents<O extends IOPayload> {
data: O data: O;
} }
/** /**
@ -408,15 +410,15 @@ interface OutputEvents<O extends IOPayload> {
*/ */
class Command<O extends IOPayload> extends EventEmitter<CommandEvents> { class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
/** @ignore Program to execute. */ /** @ignore Program to execute. */
private readonly program: string private readonly program: string;
/** @ignore Program arguments */ /** @ignore Program arguments */
private readonly args: string[] private readonly args: string[];
/** @ignore Spawn options. */ /** @ignore Spawn options. */
private readonly options: InternalSpawnOptions private readonly options: InternalSpawnOptions;
/** Event emitter for the `stdout`. Emits the `data` event. */ /** Event emitter for the `stdout`. Emits the `data` event. */
readonly stdout = new EventEmitter<OutputEvents<O>>() readonly stdout = new EventEmitter<OutputEvents<O>>();
/** Event emitter for the `stderr`. Emits the `data` event. */ /** Event emitter for the `stderr`. Emits the `data` event. */
readonly stderr = new EventEmitter<OutputEvents<O>>() readonly stderr = new EventEmitter<OutputEvents<O>>();
/** /**
* @ignore * @ignore
@ -432,23 +434,23 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
args: string | string[] = [], args: string | string[] = [],
options?: SpawnOptions options?: SpawnOptions
) { ) {
super() super();
this.program = program this.program = program;
this.args = typeof args === 'string' ? [args] : args this.args = typeof args === "string" ? [args] : args;
this.options = options ?? {} this.options = options ?? {};
} }
static create(program: string, args?: string | string[]): Command<string> static create(program: string, args?: string | string[]): Command<string>;
static create( static create(
program: string, program: string,
args?: string | string[], args?: string | string[],
options?: SpawnOptions & { encoding: 'raw' } options?: SpawnOptions & { encoding: "raw" }
): Command<Uint8Array> ): Command<Uint8Array>;
static create( static create(
program: string, program: string,
args?: string | string[], args?: string | string[],
options?: SpawnOptions options?: SpawnOptions
): Command<string> ): Command<string>;
/** /**
* Creates a command to execute the given program. * Creates a command to execute the given program.
@ -467,20 +469,20 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
args: string | string[] = [], args: string | string[] = [],
options?: SpawnOptions options?: SpawnOptions
): Command<O> { ): Command<O> {
return new Command(program, args, options) return new Command(program, args, options);
} }
static sidecar(program: string, args?: string | string[]): Command<string> static sidecar(program: string, args?: string | string[]): Command<string>;
static sidecar( static sidecar(
program: string, program: string,
args?: string | string[], args?: string | string[],
options?: SpawnOptions & { encoding: 'raw' } options?: SpawnOptions & { encoding: "raw" }
): Command<Uint8Array> ): Command<Uint8Array>;
static sidecar( static sidecar(
program: string, program: string,
args?: string | string[], args?: string | string[],
options?: SpawnOptions options?: SpawnOptions
): Command<string> ): Command<string>;
/** /**
* Creates a command to execute the given sidecar program. * Creates a command to execute the given sidecar program.
@ -499,9 +501,9 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
args: string | string[] = [], args: string | string[] = [],
options?: SpawnOptions options?: SpawnOptions
): Command<O> { ): Command<O> {
const instance = new Command<O>(program, args, options) const instance = new Command<O>(program, args, options);
instance.options.sidecar = true instance.options.sidecar = true;
return instance return instance;
} }
/** /**
@ -513,24 +515,24 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
return execute<O>( return execute<O>(
(event) => { (event) => {
switch (event.event) { switch (event.event) {
case 'Error': case "Error":
this.emit('error', event.payload) this.emit("error", event.payload);
break break;
case 'Terminated': case "Terminated":
this.emit('close', event.payload) this.emit("close", event.payload);
break break;
case 'Stdout': case "Stdout":
this.stdout.emit('data', event.payload) this.stdout.emit("data", event.payload);
break break;
case 'Stderr': case "Stderr":
this.stderr.emit('data', event.payload) this.stderr.emit("data", event.payload);
break break;
} }
}, },
this.program, this.program,
this.args, this.args,
this.options this.options
).then((pid) => new Child(pid)) ).then((pid) => new Child(pid));
} }
/** /**
@ -549,38 +551,38 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
*/ */
async execute(): Promise<ChildProcess<O>> { async execute(): Promise<ChildProcess<O>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.on('error', reject) this.on("error", reject);
const stdout: O[] = [] const stdout: O[] = [];
const stderr: O[] = [] const stderr: O[] = [];
this.stdout.on('data', (line: O) => { this.stdout.on("data", (line: O) => {
stdout.push(line) stdout.push(line);
}) });
this.stderr.on('data', (line: O) => { this.stderr.on("data", (line: O) => {
stderr.push(line) stderr.push(line);
}) });
this.on('close', (payload: TerminatedPayload) => { this.on("close", (payload: TerminatedPayload) => {
resolve({ resolve({
code: payload.code, code: payload.code,
signal: payload.signal, signal: payload.signal,
stdout: this.collectOutput(stdout) as O, 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 */ /** @ignore */
private collectOutput(events: O[]): string | Uint8Array { private collectOutput(events: O[]): string | Uint8Array {
if (this.options.encoding === 'raw') { if (this.options.encoding === "raw") {
return events.reduce<Uint8Array>((p, c) => { return events.reduce<Uint8Array>((p, c) => {
return new Uint8Array([...p, ...(c as Uint8Array), 10]) return new Uint8Array([...p, ...(c as Uint8Array), 10]);
}, new Uint8Array()) }, new Uint8Array());
} else { } else {
return events.join('\n') return events.join("\n");
} }
} }
} }
@ -589,8 +591,8 @@ class Command<O extends IOPayload> extends EventEmitter<CommandEvents> {
* Describes the event message received from the command. * Describes the event message received from the command.
*/ */
interface Event<T, V> { interface Event<T, V> {
event: T event: T;
payload: V payload: V;
} }
/** /**
@ -598,20 +600,20 @@ interface Event<T, V> {
*/ */
interface TerminatedPayload { interface TerminatedPayload {
/** Exit code of the process. `null` if the process was terminated by a signal on Unix. */ /** 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. */ /** If the process was terminated by a signal, represents that signal. */
signal: number | null signal: number | null;
} }
/** Event payload type */ /** Event payload type */
type IOPayload = string | Uint8Array type IOPayload = string | Uint8Array;
/** Events emitted by the child process. */ /** Events emitted by the child process. */
type CommandEvent<O extends IOPayload> = type CommandEvent<O extends IOPayload> =
| Event<'Stdout', O> | Event<"Stdout", O>
| Event<'Stderr', O> | Event<"Stderr", O>
| Event<'Terminated', TerminatedPayload> | Event<"Terminated", TerminatedPayload>
| Event<'Error', string> | Event<"Error", string>;
/** /**
* Opens a path or URL with the system's default app, * Opens a path or URL with the system's default app,
@ -640,18 +642,18 @@ type CommandEvent<O extends IOPayload> =
* @since 1.0.0 * @since 1.0.0
*/ */
async function open(path: string, openWith?: string): Promise<void> { async function open(path: string, openWith?: string): Promise<void> {
return invoke('plugin:shell|open', { return invoke("plugin:shell|open", {
path, path,
with: openWith with: openWith,
}) });
} }
export { Command, Child, EventEmitter, open } export { Command, Child, EventEmitter, open };
export type { export type {
IOPayload, IOPayload,
CommandEvents, CommandEvents,
TerminatedPayload, TerminatedPayload,
OutputEvents, OutputEvents,
ChildProcess, ChildProcess,
SpawnOptions SpawnOptions,
} };

@ -29,4 +29,4 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^1.2.0" "@tauri-apps/api": "^1.2.0"
} }
} }

@ -480,7 +480,7 @@ mod tests {
#[test] #[test]
fn test_cmd_output_output() { fn test_cmd_output_output() {
let cmd = Command::new("cat").args(["test/api/test.txt"]); 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!(String::from_utf8(output.stderr).unwrap(), "");
assert_eq!( assert_eq!(
@ -493,7 +493,7 @@ mod tests {
#[test] #[test]
fn test_cmd_output_output_fail() { fn test_cmd_output_output_fail() {
let cmd = Command::new("cat").args(["test/api/"]); 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!(String::from_utf8(output.stdout).unwrap(), "");
assert_eq!( assert_eq!(

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
## Usage ## Usage

@ -19,7 +19,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies.tauri-plugin-sql] [dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace" git = "https://github.com/tauri-apps/plugins-workspace"
branch = "next" branch = "v2"
features = ["sqlite"] # or "postgres", or "mysql" features = ["sqlite"] # or "postgres", or "mysql"
``` ```
@ -28,11 +28,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-sql#next pnpm add https://github.com/tauri-apps/tauri-plugin-sql#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-sql#next npm add https://github.com/tauri-apps/tauri-plugin-sql#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-sql#next yarn add https://github.com/tauri-apps/tauri-plugin-sql#v2
``` ```
## Usage ## Usage
@ -53,16 +53,16 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import Database from 'tauri-plugin-sql-api'; import Database from "tauri-plugin-sql-api";
// sqlite. The path is relative to `tauri::api::path::BaseDirectory::App`. // sqlite. The path is relative to `tauri::api::path::BaseDirectory::App`.
const db = await Database.load('sqlite:test.db'); const db = await Database.load("sqlite:test.db");
// mysql // mysql
const db = await Database.load('mysql://user:pass@host/database'); const db = await Database.load("mysql://user:pass@host/database");
// postgres // postgres
const db = await Database.load('postgres://postgres:password@localhost/test'); const db = await Database.load("postgres://postgres:password@localhost/test");
await db.execute('INSERT INTO ...'); await db.execute("INSERT INTO ...");
``` ```
## Contributing ## Contributing

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-store#next pnpm add https://github.com/tauri-apps/tauri-plugin-store#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-store#next npm add https://github.com/tauri-apps/tauri-plugin-store#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-store#next yarn add https://github.com/tauri-apps/tauri-plugin-store#v2
``` ```
## Usage ## Usage
@ -51,13 +51,13 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { Store } from 'tauri-plugin-store-api'; import { Store } from "tauri-plugin-store-api";
const store = new Store('.settings.dat'); const store = new Store(".settings.dat");
await store.set('some-key', { value: 5 }); await store.set("some-key", { value: 5 });
const val = await store.get('some-key'); const val = await store.get("some-key");
assert(val, { value: 5 }); assert(val, { value: 5 });
await store.save(); // this manually saves the store, otherwise the store is only saved when your app is closed await store.save(); // this manually saves the store, otherwise the store is only saved when your app is closed

@ -175,8 +175,9 @@ pub struct Store<R: Runtime> {
impl<R: Runtime> Store<R> { impl<R: Runtime> Store<R> {
/// Update the store from the on-disk state /// Update the store from the on-disk state
pub fn load<R: Runtime>(&mut self, app: &AppHandle<R>) -> Result<(), Error> { pub fn load(&mut self) -> Result<(), Error> {
let app_dir = app let app_dir = self
.app
.path() .path()
.app_data_dir() .app_data_dir()
.expect("failed to resolve app dir"); .expect("failed to resolve app dir");
@ -191,8 +192,9 @@ impl<R: Runtime> Store<R> {
} }
/// Saves the store to disk /// Saves the store to disk
pub fn save<R: Runtime>(&self, app: &AppHandle<R>) -> Result<(), Error> { pub fn save(&self) -> Result<(), Error> {
let app_dir = app let app_dir = self
.app
.path() .path()
.app_data_dir() .app_data_dir()
.expect("failed to resolve app dir"); .expect("failed to resolve app dir");

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-stronghold#next pnpm add https://github.com/tauri-apps/tauri-plugin-stronghold#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-stronghold#next npm add https://github.com/tauri-apps/tauri-plugin-stronghold#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-stronghold#next yarn add https://github.com/tauri-apps/tauri-plugin-stronghold#v2
``` ```
## Usage ## Usage
@ -55,7 +55,7 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { Stronghold, Location } from 'tauri-plugin-stronghold-api'; import { Stronghold, Location } from "tauri-plugin-stronghold-api";
// TODO // TODO
``` ```

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-upload#next pnpm add https://github.com/tauri-apps/tauri-plugin-upload#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-upload#next npm add https://github.com/tauri-apps/tauri-plugin-upload#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-upload#next yarn add https://github.com/tauri-apps/tauri-plugin-upload#v2
``` ```
## Usage ## Usage

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-websocket#next pnpm add https://github.com/tauri-apps/tauri-plugin-websocket#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-websocket#next npm add https://github.com/tauri-apps/tauri-plugin-websocket#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-websocket#next yarn add https://github.com/tauri-apps/tauri-plugin-websocket#v2
``` ```
## Usage ## Usage
@ -51,11 +51,11 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings: Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript ```javascript
import { WebSocket } from 'tauri-plugin-websocket-api'; import { WebSocket } from "tauri-plugin-websocket-api";
const ws = await WebSocket.connect('wss://example.com'); const ws = await WebSocket.connect("wss://example.com");
await ws.send('Hello World'); await ws.send("Hello World");
await ws.disconnect(); await ws.disconnect();
``` ```

@ -79,15 +79,14 @@ enum WebSocketMessage {
} }
#[tauri::command] #[tauri::command]
fn connect<R: Runtime>( async fn connect<R: Runtime>(
window: Window<R>, window: Window<R>,
url: String, url: String,
callback_function: CallbackFn, callback_function: CallbackFn,
config: Option<ConnectionConfig>, config: Option<ConnectionConfig>,
) -> Result<Id> { ) -> Result<Id> {
let id = rand::random(); let id = rand::random();
let (ws_stream, _) = let (ws_stream, _) = connect_async_with_config(url, config.map(Into::into)).await?;
tauri::async_runtime::block_on(connect_async_with_config(url, config.map(Into::into)))?;
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let (write, read) = ws_stream.split(); let (write, read) = ws_stream.split();

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
@ -26,11 +26,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. > 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 ```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-window-state#next pnpm add https://github.com/tauri-apps/tauri-plugin-window-state#v2
# or # or
npm add https://github.com/tauri-apps/tauri-plugin-window-state#next npm add https://github.com/tauri-apps/tauri-plugin-window-state#v2
# or # or
yarn add https://github.com/tauri-apps/tauri-plugin-window-state#next yarn add https://github.com/tauri-apps/tauri-plugin-window-state#v2
``` ```
## Usage ## Usage
@ -62,7 +62,7 @@ app.save_window_state(StateFlags::all()); // will save the state of all open win
or through Javascript or through Javascript
```javascript ```javascript
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'; import { saveWindowState, StateFlags } from "tauri-plugin-window-state-api";
saveWindowState(StateFlags.ALL); saveWindowState(StateFlags.ALL);
``` ```
@ -79,7 +79,7 @@ window.restore_state(StateFlags::all()); // will restore the windows state from
or through Javascript or through Javascript
```javascript ```javascript
import { restoreStateCurrent, StateFlags } from 'tauri-plugin-window-state-api'; import { restoreStateCurrent, StateFlags } from "tauri-plugin-window-state-api";
restoreStateCurrent(StateFlags.ALL); restoreStateCurrent(StateFlags.ALL);
``` ```

@ -37,6 +37,9 @@ importers:
eslint-plugin-promise: eslint-plugin-promise:
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1(eslint@8.39.0) version: 6.1.1(eslint@8.39.0)
eslint-plugin-security:
specifier: ^1.7.1
version: 1.7.1
prettier: prettier:
specifier: ^2.8.7 specifier: ^2.8.7
version: 2.8.8 version: 2.8.8
@ -2055,6 +2058,12 @@ packages:
eslint: 8.39.0 eslint: 8.39.0
dev: true 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: /eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -3029,6 +3038,11 @@ packages:
picomatch: 2.3.1 picomatch: 2.3.1
dev: true 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: /regexp.prototype.flags@1.4.3:
resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3125,6 +3139,12 @@ packages:
is-regex: 1.1.4 is-regex: 1.1.4
dev: true 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: /sander@0.5.1:
resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==}
dependencies: dependencies:

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml ```toml
[dependencies] [dependencies]
<!-- plugin here --> = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } <!-- plugin here --> = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
``` ```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager: You can install the JavaScript Guest bindings using your preferred JavaScript package manager:

@ -1,20 +1,20 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"lib": ["ES2019", "ES2020.Promise", "ES2020.String", "DOM", "DOM.Iterable"], "lib": ["ES2019", "ES2020.Promise", "ES2020.String", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"noEmit": true, "noEmit": true,
"noEmitOnError": false, "noEmitOnError": false,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"pretty": true, "pretty": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"target": "ES2019", "target": "ES2019",
"declaration": true, "declaration": true,
"declarationDir": "./" "declarationDir": "./"
}, },
"exclude": ["dist-js", "node_modules", "test/types"] "exclude": ["dist-js", "node_modules", "test/types"]
} }

Loading…
Cancel
Save