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": {
"javascript": {
"version": true,
"getPublishedVersion": "pnpm view ${ pkgFile.pkg.name } version",
"getPublishedVersion": "node ../../.scripts/covector/package-latest-version.js npm ${ pkgFile.pkg.name } ${ pkgFile.pkg.version }",
"publish": ["pnpm build", "pnpm publish --access public --no-git-checks"]
},
"rust": {
"version": true,
"getPublishedVersion": "cargo search ${ pkgFile.pkg.package.name } --limit 1 | sed -nE 's/^[^\"]*\"//; s/\".*//1p' -",
"getPublishedVersion": "node ../../.scripts/covector/package-latest-version.js cargo ${ pkgFile.pkg.package.name } ${ pkgFile.pkg.package.version }",
"publish": [
{
"command": "cargo package --no-verify",

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

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

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

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

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

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

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

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

@ -4,8 +4,8 @@ on:
workflow_dispatch:
push:
branches:
- dev
- next
- v1
- v2
concurrency:
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 = [
"log",
"notify-rust",
"rand 0.8.5",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-build",
"thiserror",
"time 0.3.20",
"url",
"win7-notifications",
]

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

@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
[dependencies]
tauri-plugin-authenticator = "0.1"
# or through git
tauri-plugin-authenticator = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-authenticator#next
pnpm add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-authenticator#next
npm add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-authenticator#next
yarn add https://github.com/tauri-apps/tauri-plugin-authenticator#v2
```
## Usage
@ -53,7 +53,7 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript
import { Authenticator } from 'tauri-plugin-authenticator-api';
import { Authenticator } from "tauri-plugin-authenticator-api";
const auth = new Authenticator();
auth.init(); // initialize transports
@ -63,16 +63,21 @@ const arr = new Uint32Array(32);
window.crypto.getRandomValues(arr);
const b64 = btoa(String.fromCharCode.apply(null, arr));
// 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
const json = await auth.register(challenge, domain);
const registerResult = JSON.parse(json);
// 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);
// sign some data
@ -80,10 +85,17 @@ const json = await auth.sign(challenge, app, keyHandle);
const signData = JSON.parse(json);
// 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) {
console.log('SUCCESS!');
console.log("SUCCESS!");
}
```

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

@ -8,7 +8,7 @@
* @module
*/
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from "@tauri-apps/api/tauri";
/**
* @since 1.0.0
@ -19,27 +19,27 @@ interface ArgMatch {
* boolean if flag
* string[] or null if takes multiple values
*/
value: string | boolean | string[] | null
value: string | boolean | string[] | null;
/**
* Number of occurrences
*/
occurrences: number
occurrences: number;
}
/**
* @since 1.0.0
*/
interface SubcommandMatch {
name: string
matches: CliMatches
name: string;
matches: CliMatches;
}
/**
* @since 1.0.0
*/
interface CliMatches {
args: Record<string, ArgMatch>
subcommand: SubcommandMatch | null
args: Record<string, ArgMatch>;
subcommand: SubcommandMatch | null;
}
/**
@ -64,9 +64,9 @@ interface CliMatches {
* @since 1.0.0
*/
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
[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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-clipboard#next
pnpm add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-clipboard#next
npm add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-clipboard#next
yarn add https://github.com/tauri-apps/tauri-plugin-clipboard#v2
```
## Usage

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[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:

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

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

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

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```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
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
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
@ -51,16 +51,24 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```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
const stopWatching = await watch('/path/to/something', { recursive: true }, (event) => {
const stopWatching = await watch(
"/path/to/something",
(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;
});
},
{}
);
```
## Contributing

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-fs#next
pnpm add https://github.com/tauri-apps/tauri-plugin-fs#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-fs#next
npm add https://github.com/tauri-apps/tauri-plugin-fs#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-fs#next
yarn add https://github.com/tauri-apps/tauri-plugin-fs#v2
```
## Usage
@ -51,9 +51,9 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```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

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-shortcut#next
pnpm add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-shortcut#next
npm add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-shortcut#next
yarn add https://github.com/tauri-apps/tauri-plugin-shortcut#v2
```
## Usage

@ -21,9 +21,9 @@
* @module
*/
import { invoke, transformCallback } from '@tauri-apps/api/tauri'
import { invoke, transformCallback } from "@tauri-apps/api/tauri";
export type ShortcutHandler = (shortcut: string) => void
export type ShortcutHandler = (shortcut: string) => void;
/**
* Register a global shortcut.
@ -44,10 +44,10 @@ async function register(
shortcut: string,
handler: ShortcutHandler
): Promise<void> {
return await invoke('plugin:globalShortcut|register', {
return await invoke("plugin:globalShortcut|register", {
shortcut,
handler: transformCallback(handler)
})
handler: transformCallback(handler),
});
}
/**
@ -69,10 +69,10 @@ async function registerAll(
shortcuts: string[],
handler: ShortcutHandler
): Promise<void> {
return await invoke('plugin:globalShortcut|register_all', {
return await invoke("plugin:globalShortcut|register_all", {
shortcuts,
handler: transformCallback(handler)
})
handler: transformCallback(handler),
});
}
/**
@ -91,9 +91,9 @@ async function registerAll(
* @since 1.0.0
*/
async function isRegistered(shortcut: string): Promise<boolean> {
return await invoke('plugin:globalShortcut|is_registered', {
shortcut
})
return await invoke("plugin:globalShortcut|is_registered", {
shortcut,
});
}
/**
@ -109,9 +109,9 @@ async function isRegistered(shortcut: string): Promise<boolean> {
* @since 1.0.0
*/
async function unregister(shortcut: string): Promise<void> {
return await invoke('plugin:globalShortcut|unregister', {
shortcut
})
return await invoke("plugin:globalShortcut|unregister", {
shortcut,
});
}
/**
@ -125,7 +125,7 @@ async function unregister(shortcut: string): Promise<void> {
* @since 1.0.0
*/
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
[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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-http#next
pnpm add https://github.com/tauri-apps/tauri-plugin-http#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-http#next
npm add https://github.com/tauri-apps/tauri-plugin-http#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-http#next
yarn add https://github.com/tauri-apps/tauri-plugin-http#v2
```
## Usage

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

@ -20,7 +20,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-localhost = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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
```

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-log#next
pnpm add https://github.com/tauri-apps/tauri-plugin-log#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-log#next
npm add https://github.com/tauri-apps/tauri-plugin-log#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-log#next
yarn add https://github.com/tauri-apps/tauri-plugin-log#v2
```
## Usage
@ -57,14 +57,14 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```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
const detach = await attachConsole();
trace('Trace');
info('Info');
error('Error');
trace("Trace");
info("Info");
error("Error");
// detach the browser console from the log stream
detach();

@ -52,10 +52,15 @@ async function log(
const { file, line, ...keyValues } = options ?? {};
let location = filtered?.[0]?.filter((v) => v.length > 0).join("@");
if (location === "Error") {
location = "webview::unknown";
}
await invoke("plugin:log|log", {
level,
message,
location: filtered?.[0]?.filter((v) => v.length > 0).join("@"),
location,
file,
line,
keyValues,
@ -184,7 +189,8 @@ export async function attachConsole(): Promise<UnlistenFn> {
// Strip ANSI escape codes
const message = payload.message.replace(
// eslint-disable-next-line no-control-regex
// TODO: Investigate security/detect-unsafe-regex
// eslint-disable-next-line no-control-regex, security/detect-unsafe-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
""
);

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

@ -16,6 +16,10 @@ serde_json.workspace = true
tauri.workspace = true
log.workspace = true
thiserror.workspace = true
rand = "0.8"
time = { version = "0.3", features = ["serde", "parsing", "formatting"] }
url = { version = "2", features = ["serde"] }
serde_repr = "0.1"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies]
notify-rust = "4.5"

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

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

@ -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
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.webkit.WebView
import app.tauri.PermissionState
import app.tauri.annotation.Command
import app.tauri.annotation.Permission
import app.tauri.annotation.PermissionCallback
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import org.json.JSONException
import org.json.JSONObject
const val LOCAL_NOTIFICATIONS = "permissionState"
@TauriPlugin
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "permissionState")
]
)
class NotificationPlugin(private val activity: Activity): Plugin(activity) {
private var webView: WebView? = null
private lateinit var manager: TauriNotificationManager
private lateinit var notificationManager: NotificationManager
private lateinit var notificationStorage: NotificationStorage
private var channelManager = ChannelManager(activity)
companion object {
var instance: NotificationPlugin? = null
fun triggerNotification(notification: JSObject) {
instance?.trigger("notification", notification)
}
}
override fun load(webView: WebView) {
instance = this
super.load(webView)
this.webView = webView
notificationStorage = NotificationStorage(activity)
val manager = TauriNotificationManager(
notificationStorage,
activity,
activity,
getConfig()
)
manager.createNotificationChannel()
this.manager = manager
notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (Intent.ACTION_MAIN != intent.action) {
return
}
val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage)
if (dataJson != null) {
trigger("actionPerformed", dataJson)
}
}
@Command
fun requestPermission(invoke: Invoke) {
val ret = JSObject()
ret.put("permissionState", "granted")
invoke.resolve(ret)
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 permissionState(invoke: Invoke) {
val ret = JSObject()
ret.put("permissionState", "granted")
invoke.resolve(ret)
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)
}
val ids = manager.schedule(notifications)
notificationStorage.appendNotifications(notifications)
val result = JSObject()
result.put("notifications", ids)
invoke.resolve(result)
}
@Command
fun cancel(invoke: Invoke) {
val notifications: List<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 notify(invoke: Invoke) {
// TODO
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)
}
private fun getPermissionState(): String {
return if (manager.areNotificationsEnabled()) {
"granted"
} 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
*/
import { invoke } from '@tauri-apps/api/tauri'
import { invoke, transformCallback } from '@tauri-apps/api/tauri'
/**
* Options to send a notification.
@ -32,16 +32,273 @@ import { invoke } from '@tauri-apps/api/tauri'
* @since 1.0.0
*/
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
/** Optional notification body. */
/**
* Optional notification body.
* */
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
/**
* 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. */
type Permission = 'granted' | 'denied' | 'default'
type Permission = "granted" | "denied" | "default";
/**
* Checks if the permission to send notifications is granted.
@ -54,10 +311,10 @@ type Permission = 'granted' | 'denied' | 'default'
* @since 1.0.0
*/
async function isPermissionGranted(): Promise<boolean> {
if (window.Notification.permission !== 'default') {
return Promise.resolve(window.Notification.permission === 'granted')
if (window.Notification.permission !== "default") {
return Promise.resolve(window.Notification.permission === "granted");
}
return invoke('plugin:notification|is_permission_granted')
return invoke("plugin:notification|is_permission_granted");
}
/**
@ -77,7 +334,7 @@ async function isPermissionGranted(): Promise<boolean> {
* @since 1.0.0
*/
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
*/
function sendNotification(options: Options | string): void {
if (typeof options === 'string') {
if (typeof options === "string") {
// eslint-disable-next-line no-new
new window.Notification(options)
new window.Notification(options);
} else {
// eslint-disable-next-line no-new
new window.Notification(options.title, options)
new window.Notification(options.title, options);
}
}
export type { Options, Permission }
/**
* 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 })
}
export { sendNotification, requestPermission, isPermissionGranted }
/**
* 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)
}
}
// 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 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
let package = Package(
name: "tauri-plugin-{{ plugin_name }}",
name: "tauri-plugin-notification",
platforms: [
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-{{ plugin_name }}",
name: "tauri-plugin-notification",
type: .static,
targets: ["tauri-plugin-{{ plugin_name }}"]),
targets: ["tauri-plugin-notification"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
@ -22,7 +22,7 @@ let package = Package(
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-{{ plugin_name }}",
name: "tauri-plugin-notification",
dependencies: [
.byName(name: "Tauri")
],

@ -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 UserNotifications
import WebKit
import Tauri
import SwiftRs
enum ShowNotificationError: LocalizedError {
case noId
case make(Error)
case create(Error)
var errorDescription: String? {
switch self {
case .noId:
return "notification `id` missing"
case .make(let error):
return "Unable to make notification: \(error)"
case .create(let error):
return "Unable to create notification: \(error)"
}
}
}
func showNotification(invoke: Invoke, notification: JSObject)
throws -> UNNotificationRequest
{
guard let identifier = notification["id"] as? Int else {
throw ShowNotificationError.noId
}
var content: UNNotificationContent
do {
content = try makeNotificationContent(notification)
} catch {
throw ShowNotificationError.make(error)
}
var trigger: UNNotificationTrigger?
do {
if let schedule = notification["schedule"] as? JSObject {
try trigger = handleScheduledNotification(schedule)
}
} catch {
throw ShowNotificationError.create(error)
}
// Schedule the request.
let request = UNNotificationRequest(
identifier: "\(identifier)", content: content, trigger: trigger
)
let center = UNUserNotificationCenter.current()
center.add(request) { (error: Error?) in
if let theError = error {
invoke.reject(theError.localizedDescription)
}
}
return request
}
class NotificationPlugin: Plugin {
@objc public func requestPermission(_ invoke: Invoke) throws {
invoke.resolve(["permissionState": "granted"])
let notificationHandler = NotificationHandler()
let notificationManager = NotificationManager()
override init() {
super.init()
notificationManager.notificationHandler = notificationHandler
notificationHandler.plugin = self
}
@objc public func permissionState(_ invoke: Invoke) throws {
invoke.resolve(["permissionState": "granted"])
@objc public func show(_ invoke: Invoke) throws {
let request = try showNotification(invoke: invoke, notification: invoke.data)
notificationHandler.saveNotification(request.identifier, invoke.data)
invoke.resolve([
"id": Int(request.identifier) ?? -1
])
}
@objc public func notify(_ invoke: Invoke) throws {
// TODO
@objc public func batch(_ invoke: Invoke) throws {
guard let notifications = invoke.getArray("notifications", JSObject.self) else {
invoke.reject("`notifications` array is required")
return
}
var ids = [Int]()
for notification in notifications {
let request = try showNotification(invoke: invoke, notification: notification)
notificationHandler.saveNotification(request.identifier, notification)
ids.append(Int(request.identifier) ?? -1)
}
invoke.resolve([
"notifications": ids
])
}
@objc public override func requestPermissions(_ invoke: Invoke) {
notificationHandler.requestPermissions { granted, error in
guard error == nil else {
invoke.reject(error!.localizedDescription)
return
}
invoke.resolve(["permissionState": granted ? "granted" : "denied"])
}
}
@objc public override func checkPermissions(_ invoke: Invoke) {
notificationHandler.checkPermissions { status in
let permission: String
switch status {
case .authorized, .ephemeral, .provisional:
permission = "granted"
case .denied:
permission = "denied"
case .notDetermined:
permission = "default"
@unknown default:
permission = "default"
}
invoke.resolve(["permissionState": permission])
}
}
@objc func cancel(_ invoke: Invoke) {
guard let notifications = invoke.getArray("notifications", NSNumber.self),
notifications.count > 0
else {
invoke.reject("`notifications` input is required")
return
}
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: notifications.map({ (id) -> String in
return id.stringValue
})
)
invoke.resolve()
}
@objc func getPending(_ invoke: Invoke) {
UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: {
(notifications) in
let ret = notifications.compactMap({ [weak self] (notification) -> JSObject? in
return self?.notificationHandler.makePendingNotificationRequestJSObject(notification)
})
invoke.resolve([
"notifications": ret
])
})
}
@objc func registerActionTypes(_ invoke: Invoke) throws {
guard let types = invoke.getArray("types", JSObject.self) else {
return
}
try makeCategories(types)
invoke.resolve()
}
@objc func removeActive(_ invoke: Invoke) {
if let notifications = invoke.getArray("notifications", JSObject.self) {
let ids = notifications.map { "\($0["id"] ?? "")" }
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
invoke.resolve()
} else {
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
DispatchQueue.main.async(execute: {
UIApplication.shared.applicationIconBadgeNumber = 0
})
invoke.resolve()
}
}
@objc func getActive(_ invoke: Invoke) {
UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: {
(notifications) in
let ret = notifications.map({ (notification) -> [String: Any] in
return self.notificationHandler.makeNotificationRequestJSObject(
notification.request)
})
invoke.resolve([
"notifications": ret
])
})
}
@objc func createChannel(_ invoke: Invoke) {
invoke.reject("not implemented")
}
@objc func deleteChannel(_ invoke: Invoke) {
invoke.reject("not implemented")
}
@objc func listChannels(_ invoke: Invoke) {
invoke.reject("not implemented")
}
}
@_cdecl("init_plugin_notification")
func initPlugin(name: SRString, webview: WKWebView?) {
Tauri.registerPlugin(webview: webview, name: name.toString(), plugin: NotificationPlugin())
func initPlugin() -> Plugin {
return NotificationPlugin()
}

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

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

@ -30,16 +30,6 @@ use desktop::Notification;
#[cfg(mobile)]
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.
#[derive(Debug)]
pub struct NotificationBuilder<R: Runtime> {
@ -47,7 +37,7 @@ pub struct NotificationBuilder<R: Runtime> {
app: AppHandle<R>,
#[cfg(mobile)]
handle: PluginHandle<R>,
data: NotificationData,
pub(crate) data: NotificationData,
}
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.
pub fn title(mut self, title: impl Into<String>) -> Self {
self.data.title.replace(title.into());
@ -79,11 +84,119 @@ impl<R: Runtime> NotificationBuilder<R> {
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 {
self.data.icon.replace(icon.into());
self
}
/// Notification large icon (Android).
///
/// The icon must be placed in the app's `res/drawable` folder.
pub fn large_icon(mut self, large_icon: impl Into<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.

@ -10,6 +10,8 @@ use tauri::{
use crate::models::*;
use std::collections::HashMap;
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "app.tauri.notification";
@ -31,7 +33,8 @@ pub fn init<R: Runtime, C: DeserializeOwned>(
impl<R: Runtime> crate::NotificationBuilder<R> {
pub fn show(self) -> crate::Result<()> {
self.handle
.run_mobile_plugin("notify", self.data)
.run_mobile_plugin::<ShowResponse>("show", self.data)
.map(|_| ())
.map_err(Into::into)
}
}
@ -46,17 +49,121 @@ impl<R: Runtime> Notification<R> {
pub fn request_permission(&self) -> crate::Result<PermissionState> {
self.0
.run_mobile_plugin::<PermissionResponse>("requestPermission", ())
.run_mobile_plugin::<PermissionResponse>("requestPermissions", ())
.map(|r| r.permission_state)
.map_err(Into::into)
}
pub fn permission_state(&self) -> crate::Result<PermissionState> {
self.0
.run_mobile_plugin::<PermissionResponse>("permissionState", ())
.run_mobile_plugin::<PermissionResponse>("checkPermissions", ())
.map(|r| r.permission_state)
.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)]

@ -2,10 +2,201 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::fmt::Display;
use std::{collections::HashMap, fmt::Display};
use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer};
use url::Url;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
id: String,
url: Url,
}
impl Attachment {
pub fn new(id: impl Into<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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionState {
@ -13,6 +204,8 @@ pub enum PermissionState {
Granted,
/// Permission access has been denied.
Denied,
/// Unknown state. Must request permission.
Unknown,
}
impl Display for PermissionState {
@ -20,6 +213,7 @@ impl Display for PermissionState {
match self {
Self::Granted => write!(f, "granted"),
Self::Denied => write!(f, "denied"),
Self::Unknown => write!(f, "Unknown"),
}
}
}
@ -42,7 +236,274 @@ impl<'de> Deserialize<'de> for PermissionState {
match s.to_lowercase().as_str() {
"granted" => Ok(Self::Granted),
"denied" => Ok(Self::Denied),
"default" => Ok(Self::Unknown),
_ => Err(DeError::custom(format!("unknown permission state '{s}'"))),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingNotification {
id: i32,
title: Option<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
[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

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-shell#next
pnpm add https://github.com/tauri-apps/tauri-plugin-shell#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-shell#next
npm add https://github.com/tauri-apps/tauri-plugin-shell#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-shell#next
yarn add https://github.com/tauri-apps/tauri-plugin-shell#v2
```
## Usage

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

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

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

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-store#next
pnpm add https://github.com/tauri-apps/tauri-plugin-store#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-store#next
npm add https://github.com/tauri-apps/tauri-plugin-store#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-store#next
yarn add https://github.com/tauri-apps/tauri-plugin-store#v2
```
## Usage
@ -51,13 +51,13 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```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 });
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> {
/// Update the store from the on-disk state
pub fn load<R: Runtime>(&mut self, app: &AppHandle<R>) -> Result<(), Error> {
let app_dir = app
pub fn load(&mut self) -> Result<(), Error> {
let app_dir = self
.app
.path()
.app_data_dir()
.expect("failed to resolve app dir");
@ -191,8 +192,9 @@ impl<R: Runtime> Store<R> {
}
/// Saves the store to disk
pub fn save<R: Runtime>(&self, app: &AppHandle<R>) -> Result<(), Error> {
let app_dir = app
pub fn save(&self) -> Result<(), Error> {
let app_dir = self
.app
.path()
.app_data_dir()
.expect("failed to resolve app dir");

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

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```sh
pnpm add https://github.com/tauri-apps/tauri-plugin-websocket#next
pnpm add https://github.com/tauri-apps/tauri-plugin-websocket#v2
# or
npm add https://github.com/tauri-apps/tauri-plugin-websocket#next
npm add https://github.com/tauri-apps/tauri-plugin-websocket#v2
# or
yarn add https://github.com/tauri-apps/tauri-plugin-websocket#next
yarn add https://github.com/tauri-apps/tauri-plugin-websocket#v2
```
## Usage
@ -51,11 +51,11 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```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();
```

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

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[dependencies]
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "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:
@ -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.
```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
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
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
@ -62,7 +62,7 @@ app.save_window_state(StateFlags::all()); // will save the state of all open win
or through Javascript
```javascript
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api';
import { saveWindowState, StateFlags } from "tauri-plugin-window-state-api";
saveWindowState(StateFlags.ALL);
```
@ -79,7 +79,7 @@ window.restore_state(StateFlags::all()); // will restore the windows state from
or through Javascript
```javascript
import { restoreStateCurrent, StateFlags } from 'tauri-plugin-window-state-api';
import { restoreStateCurrent, StateFlags } from "tauri-plugin-window-state-api";
restoreStateCurrent(StateFlags.ALL);
```

@ -37,6 +37,9 @@ importers:
eslint-plugin-promise:
specifier: ^6.1.1
version: 6.1.1(eslint@8.39.0)
eslint-plugin-security:
specifier: ^1.7.1
version: 1.7.1
prettier:
specifier: ^2.8.7
version: 2.8.8
@ -2055,6 +2058,12 @@ packages:
eslint: 8.39.0
dev: true
/eslint-plugin-security@1.7.1:
resolution: {integrity: sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==}
dependencies:
safe-regex: 2.1.1
dev: true
/eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
@ -3029,6 +3038,11 @@ packages:
picomatch: 2.3.1
dev: true
/regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
dev: true
/regexp.prototype.flags@1.4.3:
resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==}
engines: {node: '>= 0.4'}
@ -3125,6 +3139,12 @@ packages:
is-regex: 1.1.4
dev: true
/safe-regex@2.1.1:
resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
dependencies:
regexp-tree: 0.1.27
dev: true
/sander@0.5.1:
resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==}
dependencies:

@ -18,7 +18,7 @@ Install the Core plugin by adding the following to your `Cargo.toml` file:
```toml
[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:

Loading…
Cancel
Save