From a87a3c7d4491bf25589a0b24c285fcd5df3d6337 Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Tue, 22 Sep 2026 11:30:47 -0300 Subject: [PATCH] chore: update documentation --- .changes/update-docs.md | 61 +++ plugins/autostart/guest-js/index.ts | 37 ++ plugins/autostart/src/lib.rs | 59 ++- plugins/barcode-scanner/guest-js/index.ts | 131 +++++- plugins/barcode-scanner/src/error.rs | 5 + plugins/barcode-scanner/src/lib.rs | 5 + plugins/biometric/guest-js/index.ts | 54 ++- plugins/biometric/src/error.rs | 6 + plugins/biometric/src/lib.rs | 11 + plugins/biometric/src/models.rs | 13 + plugins/cli/guest-js/index.ts | 11 + plugins/cli/src/error.rs | 4 + plugins/cli/src/lib.rs | 10 + plugins/clipboard-manager/guest-js/index.ts | 8 + plugins/clipboard-manager/src/desktop.rs | 25 + plugins/clipboard-manager/src/error.rs | 5 + plugins/clipboard-manager/src/lib.rs | 1 + plugins/clipboard-manager/src/mobile.rs | 40 ++ plugins/deep-link/guest-js/index.ts | 21 +- plugins/deep-link/src/error.rs | 13 + plugins/deep-link/src/lib.rs | 9 + plugins/dialog/guest-js/index.ts | 37 +- plugins/dialog/guest-js/init.ts | 7 + plugins/dialog/src/error.rs | 7 + plugins/dialog/src/lib.rs | 10 + plugins/dialog/src/models.rs | 5 + plugins/fs/guest-js/index.ts | 188 +++++++- plugins/fs/src/android.rs | 6 + plugins/fs/src/desktop.rs | 3 + plugins/fs/src/error.rs | 8 + plugins/fs/src/ios.rs | 5 + plugins/fs/src/lib.rs | 70 ++- plugins/geolocation/guest-js/index.ts | 105 ++++- plugins/geolocation/src/desktop.rs | 5 + plugins/geolocation/src/error.rs | 3 + plugins/geolocation/src/lib.rs | 1 + plugins/geolocation/src/mobile.rs | 4 + plugins/geolocation/src/models.rs | 12 +- plugins/global-shortcut/guest-js/index.ts | 12 +- plugins/global-shortcut/src/error.rs | 7 + plugins/global-shortcut/src/lib.rs | 65 ++- plugins/haptics/api-iife.js | 2 +- plugins/haptics/guest-js/bindings.ts | 14 + plugins/haptics/guest-js/index.ts | 100 +++- plugins/haptics/src/desktop.rs | 8 + plugins/haptics/src/error.rs | 5 + plugins/haptics/src/lib.rs | 1 + plugins/haptics/src/mobile.rs | 29 ++ plugins/haptics/src/models.rs | 18 + plugins/http/guest-js/index.ts | 21 + plugins/http/src/error.rs | 27 ++ plugins/http/src/lib.rs | 10 + plugins/localhost/src/lib.rs | 46 +- plugins/log/guest-js/index.ts | 100 ++-- plugins/log/src/lib.rs | 63 ++- plugins/nfc/guest-js/index.ts | 227 ++++++++- plugins/nfc/src/error.rs | 9 + plugins/nfc/src/lib.rs | 38 ++ plugins/nfc/src/models.rs | 76 +++ plugins/notification/guest-js/index.ts | 370 ++++++++++++++- plugins/notification/guest-js/init.ts | 11 + plugins/notification/src/desktop.rs | 41 ++ plugins/notification/src/error.rs | 8 + plugins/notification/src/lib.rs | 23 +- plugins/notification/src/mobile.rs | 54 ++- plugins/notification/src/models.rs | 258 +++++++++++ plugins/opener/guest-js/init.ts | 11 + plugins/opener/src/error.rs | 28 +- plugins/opener/src/lib.rs | 23 + plugins/os/guest-js/index.ts | 29 ++ plugins/os/src/error.rs | 3 + plugins/os/src/lib.rs | 7 + plugins/persisted-scope/src/lib.rs | 8 + plugins/positioner/guest-js/index.ts | 80 ++++ plugins/positioner/src/ext.rs | 25 + plugins/positioner/src/lib.rs | 10 + plugins/process/src/lib.rs | 1 + plugins/shell/guest-js/index.ts | 360 ++++++++++++++- plugins/shell/guest-js/init.ts | 11 + plugins/shell/src/error.rs | 11 + plugins/shell/src/lib.rs | 30 ++ plugins/single-instance/src/lib.rs | 31 ++ plugins/sql/guest-js/index.ts | 72 ++- plugins/sql/src/error.rs | 14 + plugins/sql/src/lib.rs | 56 +++ plugins/sql/src/wrapper.rs | 10 + plugins/store/guest-js/index.ts | 483 +++++++++++++++++++- plugins/store/src/error.rs | 3 + plugins/store/src/lib.rs | 20 + plugins/store/src/store.rs | 23 + plugins/stronghold/guest-js/index.ts | 390 ++++++++++++++-- plugins/stronghold/src/kdf.rs | 19 +- plugins/stronghold/src/lib.rs | 41 +- plugins/stronghold/src/stronghold.rs | 32 ++ plugins/updater/guest-js/index.ts | 140 +++++- plugins/updater/src/error.rs | 13 + plugins/updater/src/lib.rs | 46 ++ plugins/updater/src/updater.rs | 115 +++++ plugins/upload/guest-js/index.ts | 96 +++- plugins/upload/src/lib.rs | 16 + plugins/websocket/guest-js/index.ts | 99 ++++ plugins/websocket/src/lib.rs | 9 + plugins/window-state/guest-js/index.ts | 58 +++ plugins/window-state/src/lib.rs | 26 ++ 104 files changed, 4875 insertions(+), 222 deletions(-) create mode 100644 .changes/update-docs.md diff --git a/.changes/update-docs.md b/.changes/update-docs.md new file mode 100644 index 000000000..1d491b11d --- /dev/null +++ b/.changes/update-docs.md @@ -0,0 +1,61 @@ +--- +"autostart": patch +"barcode-scanner": patch +"biometric": patch +"cli": patch +"clipboard-manager": patch +"deep-link": patch +"dialog": patch +"fs": patch +"global-shortcut": patch +"http": patch +"localhost": patch +"log": patch +"nfc": patch +"notification": patch +"opener": patch +"os": patch +"persisted-scope": patch +"positioner": patch +"process": patch +"shell": patch +"single-instance": patch +"sql": patch +"store": patch +"stronghold": patch +"updater": patch +"upload": patch +"websocket": patch +"window-state": patch +"autostart-js": patch +"barcode-scanner-js": patch +"biometric-js": patch +"cli-js": patch +"clipboard-manager-js": patch +"deep-link-js": patch +"dialog-js": patch +"fs-js": patch +"global-shortcut-js": patch +"http-js": patch +"log-js": patch +"nfc-js": patch +"notification-js": patch +"opener-js": patch +"os-js": patch +"positioner-js": patch +"process-js": patch +"shell-js": patch +"sql-js": patch +"store-js": patch +"stronghold-js": patch +"updater-js": patch +"upload-js": patch +"websocket-js": patch +"window-state-js": patch +"haptics": patch +"haptics-js": patch +"geolocation": patch +"geolocation-js": patch +--- + +Update documentation. diff --git a/plugins/autostart/guest-js/index.ts b/plugins/autostart/guest-js/index.ts index fca8344ff..063066b7f 100644 --- a/plugins/autostart/guest-js/index.ts +++ b/plugins/autostart/guest-js/index.ts @@ -2,16 +2,53 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Automatically launch your application at startup. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' +/** + * Checks whether autostart is enabled for the application. + * @example + * ```typescript + * import { isEnabled } from '@tauri-apps/plugin-autostart'; + * const enabled = await isEnabled(); + * ``` + * + * @returns A promise resolving to `true` if the application launches at startup, `false` otherwise. + * @since 2.0.0 + */ export async function isEnabled(): Promise { return await invoke('plugin:autostart|is_enabled') } +/** + * Enables autostart for the application. + * @example + * ```typescript + * import { enable } from '@tauri-apps/plugin-autostart'; + * await enable(); + * ``` + * + * @since 2.0.0 + */ export async function enable(): Promise { await invoke('plugin:autostart|enable') } +/** + * Disables autostart for the application. + * @example + * ```typescript + * import { disable } from '@tauri-apps/plugin-autostart'; + * await disable(); + * ``` + * + * @since 2.0.0 + */ export async function disable(): Promise { await invoke('plugin:autostart|disable') } diff --git a/plugins/autostart/src/lib.rs b/plugins/autostart/src/lib.rs index 82fc56fc9..4ff9503d1 100644 --- a/plugins/autostart/src/lib.rs +++ b/plugins/autostart/src/lib.rs @@ -22,17 +22,28 @@ use std::env::current_exe; type Result = std::result::Result; +/// The strategy used to register the application for auto start on macOS. +/// +/// The builder's default is [`MacosLauncher::LaunchAgent`]. #[derive(Debug, Default, Copy, Clone)] pub enum MacosLauncher { + /// Auto start by installing a Launch Agent plist under `~/Library/LaunchAgents`, + /// which macOS starts automatically at login. #[default] LaunchAgent, + /// Auto start by adding a login item through an AppleScript command sent to the + /// "System Events" application. AppleScript, } +/// The error type of this plugin. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error, for example while resolving the current executable's path. #[error(transparent)] Io(#[from] std::io::Error), + /// An error forwarded from the underlying `auto_launch` operation, converted to its + /// string representation. #[error("{0}")] Anyhow(String), } @@ -46,9 +57,19 @@ impl Serialize for Error { } } +/// Manages the auto start (launch at login) state of the application. +/// +/// An instance is created and managed as Tauri state when the plugin is built; access it +/// through [`ManagerExt::autolaunch`]. pub struct AutoLaunchManager(AutoLaunch); impl AutoLaunchManager { + /// Enables auto start, registering the application to launch at login. + /// + /// ## Errors + /// + /// Returns [`Error::Anyhow`] if the platform-specific registration fails, for example + /// when the application path does not exist or is not absolute. pub fn enable(&self) -> Result<()> { self.0 .enable() @@ -56,6 +77,11 @@ impl AutoLaunchManager { .map_err(Error::Anyhow) } + /// Disables auto start, removing the application from the list of programs launched at login. + /// + /// ## Errors + /// + /// Returns [`Error::Anyhow`] if the platform-specific removal fails. pub fn disable(&self) -> Result<()> { self.0 .disable() @@ -63,6 +89,11 @@ impl AutoLaunchManager { .map_err(Error::Anyhow) } + /// Returns whether auto start is currently enabled for the application. + /// + /// ## Errors + /// + /// Returns [`Error::Anyhow`] if the platform-specific check fails. pub fn is_enabled(&self) -> Result { self.0 .is_enabled() @@ -71,6 +102,8 @@ impl AutoLaunchManager { } } +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] +/// and [`tauri::Window`] to access the autostart APIs. pub trait ManagerExt { /// TODO: Rename these to `autostart` or `auto_start` in v3 fn autolaunch(&self) -> State<'_, AutoLaunchManager>; @@ -98,6 +131,8 @@ async fn is_enabled(manager: State<'_, AutoLaunchManager>) -> Result { manager.is_enabled() } +/// Builder for the autostart plugin, used to configure the startup arguments, application +/// name, and — on macOS — the launch strategy before calling [`Builder::build`]. #[derive(Default)] pub struct Builder { #[cfg(target_os = "macos")] @@ -116,11 +151,10 @@ impl Builder { /// /// ## Examples /// - /// ```no_run - /// Builder::new() + /// ``` + /// tauri_plugin_autostart::Builder::new() /// .arg("--from-autostart") - /// .arg("--hey") - /// .build(); + /// .arg("--hey"); /// ``` pub fn arg>(mut self, arg: S) -> Self { self.args.push(arg.into()); @@ -131,10 +165,8 @@ impl Builder { /// /// ## Examples /// - /// ```no_run - /// Builder::new() - /// .args(["--from-autostart", "--hey"]) - /// .build(); + /// ``` + /// tauri_plugin_autostart::Builder::new().args(["--from-autostart", "--hey"]); /// ``` pub fn args(mut self, args: I) -> Self where @@ -159,16 +191,19 @@ impl Builder { /// /// ## Examples /// - /// ```no_run - /// Builder::new() - /// .app_name("My Custom Name") - /// .build(); + /// ``` + /// tauri_plugin_autostart::Builder::new().app_name("My Custom Name"); /// ``` pub fn app_name>(mut self, app_name: S) -> Self { self.app_name = Some(app_name.into()); self } + /// Builds the autostart [`TauriPlugin`] with the configured options. + /// + /// On setup, it resolves the current executable's path (or the AppImage path on Linux, + /// when available) and manages an [`AutoLaunchManager`] built from it, so that + /// [`ManagerExt::autolaunch`] can be used from anywhere in the application. pub fn build(self) -> TauriPlugin { PluginBuilder::new("autostart") .invoke_handler(tauri::generate_handler![enable, disable, is_enabled]) diff --git a/plugins/barcode-scanner/guest-js/index.ts b/plugins/barcode-scanner/guest-js/index.ts index 9781b4bae..f74b32a72 100644 --- a/plugins/barcode-scanner/guest-js/index.ts +++ b/plugins/barcode-scanner/guest-js/index.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Scan QR codes, EAN-13 and other kinds of barcodes with the device's camera on Android and iOS. + * + * @module + */ + import { invoke, requestPermissions as requestPermissions_, @@ -10,55 +16,136 @@ import { export type { PermissionState } from '@tauri-apps/api/core' +/** + * The barcode symbologies that can be scanned, or used to restrict a scan via {@link ScanOptions.formats}. + */ export enum Format { + /** + * QR code, a two-dimensional matrix barcode. + */ QRCode = 'QR_CODE', /** + * UPC-A, a 12-digit numeric barcode commonly used on retail products in North America. + * * Not supported on iOS. */ UPC_A = 'UPC_A', + /** + * UPC-E, a compressed 6-digit variant of UPC-A used on small packaging. + */ UPC_E = 'UPC_E', + /** + * EAN-8, an 8-digit numeric barcode used on small retail packaging. + */ EAN8 = 'EAN_8', + /** + * EAN-13, a 13-digit numeric barcode used worldwide on retail products. + */ EAN13 = 'EAN_13', + /** + * Code 39, an alphanumeric barcode used in logistics and inventory tracking. + */ Code39 = 'CODE_39', + /** + * Code 93, a compact alphanumeric barcode similar to Code 39. + */ Code93 = 'CODE_93', + /** + * Code 128, a high-density alphanumeric barcode used in shipping and packaging. + */ Code128 = 'CODE_128', /** + * Codabar, a numeric barcode commonly used by libraries and blood banks. + * * Not supported on iOS. */ Codabar = 'CODABAR', + /** + * ITF (Interleaved 2 of 5), a numeric barcode encoding an even number of digits. + */ ITF = 'ITF', + /** + * Aztec code, a two-dimensional matrix barcode often used on tickets and boarding passes. + */ Aztec = 'AZTEC', + /** + * Data Matrix, a two-dimensional matrix barcode used to encode small amounts of data. + */ DataMatrix = 'DATA_MATRIX', + /** + * PDF417, a stacked linear barcode used on IDs, boarding passes and shipping labels. + */ PDF417 = 'PDF_417', /** + * GS1 DataBar, a compact barcode used to mark variable-measure items such as fresh food. + * * Not supported on Android. Requires iOS 15.4+ */ GS1DataBar = 'GS1_DATA_BAR', /** + * The limited variant of {@link Format.GS1DataBar}, encoding fewer digits in a smaller symbol. + * * Not supported on Android. Requires iOS 15.4+ */ GS1DataBarLimited = 'GS1_DATA_BAR_LIMITED', /** + * The expanded variant of {@link Format.GS1DataBar}, capable of encoding additional data such as weight. + * * Not supported on Android. Requires iOS 15.4+ */ GS1DataBarExpanded = 'GS1_DATA_BAR_EXPANDED' } +/** + * Options to configure a {@link scan} call. + */ export interface ScanOptions { + /** + * Which camera to use for scanning. Defaults to `back`. + */ cameraDirection?: 'back' | 'front' + /** + * The barcode formats to scan for. Defaults to all supported formats. + */ formats?: Format[] + /** + * Whether to show the camera in a small window instead of taking over the whole screen. Defaults to `false`. + */ windowed?: boolean } +/** + * The result of a successful {@link scan} call. + */ export interface Scanned { + /** + * The decoded content of the barcode. + */ content: string + /** + * The format of the scanned barcode. + */ format: Format + /** + * The bounding box of the scanned barcode within the camera frame, when reported by the platform. + */ bounds: unknown } /** - * Start scanning. - * @param options + * Start scanning, opening the device's camera. The returned promise resolves once a barcode + * matching the given options has been scanned, or rejects if the scan is cancelled. + * + * @example + * ```typescript + * import { scan, Format } from '@tauri-apps/plugin-barcode-scanner'; + * + * const scanned = await scan({ windowed: true, formats: [Format.QRCode] }); + * ``` + * + * @param options Configuration for the scan. + * @returns A promise resolving to the scanned barcode. + * @since 2.0.0 */ export async function scan(options?: ScanOptions): Promise { return await invoke('plugin:barcode-scanner|scan', { ...options }) @@ -66,13 +153,32 @@ export async function scan(options?: ScanOptions): Promise { /** * Cancel the current scan process. + * + * @example + * ```typescript + * import { cancel } from '@tauri-apps/plugin-barcode-scanner'; + * + * await cancel(); + * ``` + * + * @since 2.0.0 */ export async function cancel(): Promise { await invoke('plugin:barcode-scanner|cancel') } /** - * Get permission state. + * Get the current state of the camera permission. + * + * @example + * ```typescript + * import { checkPermissions } from '@tauri-apps/plugin-barcode-scanner'; + * + * const permissionState = await checkPermissions(); + * ``` + * + * @returns A promise resolving to the current state of the camera permission. + * @since 2.0.0 */ export async function checkPermissions(): Promise { return await checkPermissions_<{ camera: PermissionState }>( @@ -82,6 +188,16 @@ export async function checkPermissions(): Promise { /** * Request permissions to use the camera. + * + * @example + * ```typescript + * import { requestPermissions } from '@tauri-apps/plugin-barcode-scanner'; + * + * const permissionState = await requestPermissions(); + * ``` + * + * @returns A promise resolving to the new state of the camera permission. + * @since 2.0.0 */ export async function requestPermissions(): Promise { return await requestPermissions_<{ camera: PermissionState }>( @@ -91,6 +207,15 @@ export async function requestPermissions(): Promise { /** * Open application settings. Useful if permission was denied and the user must manually enable it. + * + * @example + * ```typescript + * import { openAppSettings } from '@tauri-apps/plugin-barcode-scanner'; + * + * await openAppSettings(); + * ``` + * + * @since 2.0.0 */ export async function openAppSettings(): Promise { await invoke('plugin:barcode-scanner|open_app_settings') diff --git a/plugins/barcode-scanner/src/error.rs b/plugins/barcode-scanner/src/error.rs index 339e763b1..5aabafd92 100644 --- a/plugins/barcode-scanner/src/error.rs +++ b/plugins/barcode-scanner/src/error.rs @@ -4,12 +4,17 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for the result type returned by this crate's functions. pub type Result = std::result::Result; +/// The error type returned by this crate's APIs. Serialized as its [`Display`](std::fmt::Display) +/// string when it crosses the IPC boundary. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error occurred. #[error(transparent)] Io(#[from] std::io::Error), + /// Failed to run a command on the mobile plugin implementation (Kotlin on Android, Swift on iOS). #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), diff --git a/plugins/barcode-scanner/src/lib.rs b/plugins/barcode-scanner/src/lib.rs index 2f2e7ee95..0266b7e38 100644 --- a/plugins/barcode-scanner/src/lib.rs +++ b/plugins/barcode-scanner/src/lib.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Scan QR codes, EAN-13 and other kinds of barcodes with the device's camera. +//! +//! - Supported platforms: Android and iOS. + #![cfg(mobile)] use tauri::{ @@ -29,6 +33,7 @@ impl BarcodeScanner {} /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the barcode scanner APIs. pub trait BarcodeScannerExt { + /// Returns the [`BarcodeScanner`] instance managed by the app. fn barcode_scanner(&self) -> &BarcodeScanner; } diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index 5c3eb8df2..cf17bc69b 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -2,22 +2,40 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Prompt the user for biometric authentication on Android and iOS. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' +/** + * The kind of biometry hardware detected on the device. + */ export enum BiometryType { + /** No biometry hardware is available, or it is not enrolled with the operating system. */ None = 0, - // Apple TouchID or Android fingerprint + /** Apple TouchID or Android fingerprint. */ TouchID = 1, - // Apple FaceID or Android face authentication + /** Apple FaceID or Android face authentication. */ FaceID = 2, - // Android iris authentication + /** Android iris authentication. */ Iris = 3 } +/** + * The result of {@linkcode checkStatus}, describing whether biometric authentication can + * currently be used. + */ export interface Status { + /** Whether the device can currently authenticate using biometrics. */ isAvailable: boolean + /** The kind of biometry hardware detected on the device, even when {@linkcode isAvailable} is `false`. */ biometryType: BiometryType + /** A human-readable reason why biometric authentication is unavailable. Only set when {@linkcode isAvailable} is `false`. */ error?: string + /** A platform-specific error code describing why biometric authentication is unavailable. Only set when {@linkcode isAvailable} is `false`. */ errorCode?: | 'appCancel' | 'authenticationFailed' @@ -32,23 +50,43 @@ export interface Status { | 'biometryNotEnrolled' } +/** + * Options for the {@linkcode authenticate} biometric prompt. + */ export interface AuthOptions { + /** Enables authentication using the device's password or PIN. Available on both Android and iOS. */ allowDeviceCredential?: boolean + /** Label for the cancel button. Available on both Android and iOS. */ cancelTitle?: string // iOS options + /** Text displayed on the fallback button if biometric authentication fails. **iOS only.** */ fallbackTitle?: string // android options + /** Title indicating the purpose of the biometric verification. **Android only.** */ title?: string + /** Subtitle providing contextual information of the biometric verification. **Android only.** */ subtitle?: string + /** Whether additional user confirmation is required, such as pressing a button, after successful biometric authentication. **Android only.** */ confirmationRequired?: boolean + /** Maximum number of attempts allowed before the prompt is dismissed. Defaults to `3`. **Android only.** */ maxAttemps?: number } /** * Checks if the biometric authentication is available. + * @example + * ```typescript + * import { checkStatus } from '@tauri-apps/plugin-biometric'; + * + * const status = await checkStatus(); + * if (status.isAvailable) { + * // do something + * } + * ``` * @returns a promise resolving to an object containing all the information about the status of the biometry. + * @since 2.0.0 */ export async function checkStatus(): Promise { return await invoke('plugin:biometric|status') @@ -58,13 +96,15 @@ export async function checkStatus(): Promise { * Prompts the user for authentication using the system interface (touchID, faceID or Android Iris). * Rejects if the authentication fails. * - * ```javascript + * @example + * ```typescript * import { authenticate } from "@tauri-apps/plugin-biometric"; * await authenticate('Open your wallet'); * ``` - * @param reason - * @param options - * @returns + * @param reason A message shown to the user explaining why authentication is requested. + * @param options Configuration for the biometric prompt. + * @returns a promise resolving to `void` once the user is successfully authenticated. + * @since 2.0.0 */ export async function authenticate( reason: string, diff --git a/plugins/biometric/src/error.rs b/plugins/biometric/src/error.rs index 339e763b1..1be5a6639 100644 --- a/plugins/biometric/src/error.rs +++ b/plugins/biometric/src/error.rs @@ -4,12 +4,18 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; +/// The error types returned by this plugin. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error occurred. #[error(transparent)] Io(#[from] std::io::Error), + /// The invocation of the underlying Android or iOS plugin failed, for example because + /// biometric authentication was unavailable, was not enrolled, failed, or was canceled by + /// the user. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), diff --git a/plugins/biometric/src/lib.rs b/plugins/biometric/src/lib.rs index f79a104d3..01303d514 100644 --- a/plugins/biometric/src/lib.rs +++ b/plugins/biometric/src/lib.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Prompt the user for biometric authentication. +//! +//! - Supported platforms: Android and iOS. + #![cfg(mobile)] use serde::Serialize; @@ -34,10 +38,16 @@ struct AuthenticatePayload { } impl Biometric { + /// Checks the device's availability and type of biometric authentication, as reported by the + /// operating system. Errors if the underlying mobile plugin invocation fails. pub fn status(&self) -> crate::Result { self.0.run_mobile_plugin("status", ()).map_err(Into::into) } + /// Prompts the user for biometric authentication using the system UI (Android + /// `BiometricPrompt` or iOS `LocalAuthentication`), showing `reason` as the purpose of the + /// request. Resolves once the user is authenticated and errors if authentication fails, is + /// canceled, or the underlying mobile plugin invocation fails. pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result<()> { self.0 .run_mobile_plugin("authenticate", AuthenticatePayload { reason, options }) @@ -47,6 +57,7 @@ impl Biometric { /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the biometric APIs. pub trait BiometricExt { + /// Returns the [`Biometric`] instance managed by the app. fn biometric(&self) -> &Biometric; } diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs index 49c843004..33547d364 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; +/// Options for [`Biometric::authenticate`](crate::Biometric::authenticate). #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthOptions { @@ -21,19 +22,31 @@ pub struct AuthOptions { pub confirmation_required: Option, } +/// The kind of biometry hardware detected on the device. #[derive(Debug, Clone, serde_repr::Deserialize_repr)] #[repr(u8)] pub enum BiometryType { + /// No biometry hardware is available, or it is not enrolled with the operating system. None = 0, + /// Fingerprint authentication (Apple Touch ID or Android fingerprint). TouchID = 1, + /// Face authentication (Apple Face ID or Android face authentication). FaceID = 2, } +/// The result of [`Biometric::status`](crate::Biometric::status), describing whether biometric +/// authentication can currently be used. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Status { + /// Whether the device can currently authenticate using biometrics. pub is_available: bool, + /// The kind of biometry hardware detected on the device, even when [`Self::is_available`] is `false`. pub biometry_type: BiometryType, + /// A human-readable reason why biometric authentication is unavailable. Only set when + /// [`Self::is_available`] is `false`. pub error: Option, + /// A platform-specific error code describing why biometric authentication is unavailable. + /// Only set when [`Self::is_available`] is `false`. pub error_code: Option, } diff --git a/plugins/cli/guest-js/index.ts b/plugins/cli/guest-js/index.ts index 7a7f4acf3..153a4ffe2 100644 --- a/plugins/cli/guest-js/index.ts +++ b/plugins/cli/guest-js/index.ts @@ -11,6 +11,8 @@ import { invoke } from '@tauri-apps/api/core' /** + * The resolution of a single CLI argument match. + * * @since 2.0.0 */ interface ArgMatch { @@ -27,18 +29,26 @@ interface ArgMatch { } /** + * The result of matching a subcommand of the CLI definition. + * * @since 2.0.0 */ interface SubcommandMatch { + /** The name of the matched subcommand. */ name: string + /** The argument matches of the subcommand, resolved the same way as the parent command's matches. */ matches: CliMatches } /** + * The resolved matches of the CLI arguments and, if any, its matched subcommand. + * * @since 2.0.0 */ interface CliMatches { + /** The matched arguments, keyed by argument name. */ args: Record + /** The matched subcommand, or `null` if no subcommand was invoked. */ subcommand: SubcommandMatch | null } @@ -61,6 +71,7 @@ interface CliMatches { * } * ``` * + * @returns A promise resolving to the parsed CLI matches. * @since 2.0.0 */ async function getMatches(): Promise { diff --git a/plugins/cli/src/error.rs b/plugins/cli/src/error.rs index 2b5e16029..66455352a 100644 --- a/plugins/cli/src/error.rs +++ b/plugins/cli/src/error.rs @@ -4,8 +4,11 @@ use serde::{Serialize, Serializer}; +/// Errors that can be returned from the CLI plugin's commands. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Parsing the process arguments against the CLI definition in `tauri.conf.json` failed, + /// e.g. because a required argument is missing or an unknown flag was passed. #[error("failed to parse arguments: {0}")] ParseCli(#[from] clap::Error), } @@ -19,4 +22,5 @@ impl Serialize for Error { } } +/// Alias for a [`std::result::Result`] with the error type [`Error`]. pub type Result = std::result::Result; diff --git a/plugins/cli/src/lib.rs b/plugins/cli/src/lib.rs index e927a348b..ad133672f 100644 --- a/plugins/cli/src/lib.rs +++ b/plugins/cli/src/lib.rs @@ -25,19 +25,27 @@ use config::{Arg, Config}; pub use error::{Error, Result}; pub use parser::{ArgData, Matches, SubcommandMatches}; +/// Access to the CLI APIs, managed by the app once the plugin is initialized. pub struct Cli(PluginApi); impl Cli { + /// Parses the arguments the current process was started with against the CLI definition + /// configured under `plugins.cli` in `tauri.conf.json` and returns the resolved + /// [`parser::Matches`]. Errors if the arguments do not satisfy the CLI definition. pub fn matches(&self) -> Result { parser::get_matches(self.0.config(), self.0.app().package_info(), None) } + /// Same as [`Self::matches`], but parses the given `args` instead of the current process' + /// arguments. Errors if `args` does not satisfy the CLI definition. pub fn matches_from(&self, args: Vec) -> Result { parser::get_matches(self.0.config(), self.0.app().package_info(), Some(args)) } } +/// Extension trait to access the CLI APIs. pub trait CliExt { + /// Returns the [`Cli`] instance managed by the app. fn cli(&self) -> &Cli; } @@ -52,6 +60,8 @@ fn cli_matches(_app: AppHandle, cli: State<'_, Cli>) -> Result cli.matches() } +/// Initializes the plugin, reading the CLI definition from the `plugins.cli` object in +/// `tauri.conf.json`. pub fn init() -> TauriPlugin { Builder::new("cli") .invoke_handler(tauri::generate_handler![cli_matches]) diff --git a/plugins/clipboard-manager/guest-js/index.ts b/plugins/clipboard-manager/guest-js/index.ts index a37bbfab1..bea789ee1 100644 --- a/plugins/clipboard-manager/guest-js/index.ts +++ b/plugins/clipboard-manager/guest-js/index.ts @@ -20,6 +20,9 @@ import { Image, transformImage } from '@tauri-apps/api/image' * assert(await readText(), 'Tauri is awesome!'); * ``` * + * @param text The plain text to write to the clipboard. + * @param opts Additional configuration for the write operation. + * @param opts.label A label describing the copied content. **Android only**, ignored on other platforms. * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -41,6 +44,7 @@ async function writeText( * import { readText } from '@tauri-apps/plugin-clipboard-manager'; * const clipboardText = await readText(); * ``` + * @returns A promise resolving to the clipboard contents as plain text. * @since 2.0.0 */ async function readText(): Promise { @@ -67,6 +71,7 @@ async function readText(): Promise { * await writeImage(buffer); * ``` * + * @param image The image to write, as a path, raw RGBA bytes, or an existing {@link Image}. * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -94,6 +99,7 @@ async function writeImage( * const blob = new Blob([await clipboardImage.rgba()], { type: 'image' }) * const url = URL.createObjectURL(blob) * ``` + * @returns A promise resolving to the clipboard contents as an {@link Image}. * @since 2.0.0 */ async function readImage(): Promise { @@ -119,6 +125,8 @@ async function readImage(): Promise { * assert(await readText(), '

Tauri is awesome!

'); * ``` * + * @param html The HTML markup to write to the clipboard. + * @param altText The plain text fallback written alongside the HTML, used by targets that cannot render it. * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 diff --git a/plugins/clipboard-manager/src/desktop.rs b/plugins/clipboard-manager/src/desktop.rs index f3570cc0c..b31b509cd 100644 --- a/plugins/clipboard-manager/src/desktop.rs +++ b/plugins/clipboard-manager/src/desktop.rs @@ -28,6 +28,12 @@ pub struct Clipboard { } impl Clipboard { + /// Writes plain text to the system clipboard. + /// + /// # Errors + /// + /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the + /// underlying [`arboard`] operation fails. pub fn write_text<'a, T: Into>>(&self, text: T) -> crate::Result<()> { match &self.clipboard { Ok(clipboard) => clipboard @@ -41,6 +47,12 @@ impl Clipboard { } } + /// Writes an image to the system clipboard as RGBA data. + /// + /// # Errors + /// + /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the + /// underlying [`arboard`] operation fails. pub fn write_image(&self, image: &Image<'_>) -> crate::Result<()> { match &self.clipboard { Ok(clipboard) => clipboard @@ -69,6 +81,13 @@ impl Clipboard { } } + /// Writes HTML to the system clipboard, with an optional plain text fallback for targets + /// that cannot render it. + /// + /// # Errors + /// + /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the + /// underlying [`arboard`] operation fails. pub fn write_html<'a, T: Into>>( &self, html: T, @@ -86,6 +105,12 @@ impl Clipboard { } } + /// Clears the system clipboard. + /// + /// # Errors + /// + /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the + /// underlying [`arboard`] operation fails. pub fn clear(&self) -> crate::Result<()> { match &self.clipboard { Ok(clipboard) => clipboard diff --git a/plugins/clipboard-manager/src/error.rs b/plugins/clipboard-manager/src/error.rs index 1b8cf482b..6f7e6c4d9 100644 --- a/plugins/clipboard-manager/src/error.rs +++ b/plugins/clipboard-manager/src/error.rs @@ -4,15 +4,20 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for `Result` used throughout this crate. pub type Result = std::result::Result; +/// Errors that can occur while interacting with the system clipboard. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Forwarding a request to, or receiving a response from, the mobile plugin failed. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The underlying clipboard operation failed, or the operation is not supported on this platform. #[error("{0}")] Clipboard(String), + /// An error forwarded from the Tauri core. #[error(transparent)] Tauri(#[from] tauri::Error), } diff --git a/plugins/clipboard-manager/src/lib.rs b/plugins/clipboard-manager/src/lib.rs index 0cbb4e41e..84ea87e43 100644 --- a/plugins/clipboard-manager/src/lib.rs +++ b/plugins/clipboard-manager/src/lib.rs @@ -31,6 +31,7 @@ pub use mobile::Clipboard; /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the clipboard APIs. pub trait ClipboardExt { + /// Returns a handle to the [`Clipboard`] APIs. fn clipboard(&self) -> &Clipboard; } diff --git a/plugins/clipboard-manager/src/mobile.rs b/plugins/clipboard-manager/src/mobile.rs index 72d5f6e06..7eae15552 100644 --- a/plugins/clipboard-manager/src/mobile.rs +++ b/plugins/clipboard-manager/src/mobile.rs @@ -34,6 +34,11 @@ pub fn init( pub struct Clipboard(PluginHandle); impl Clipboard { + /// Writes plain text to the system clipboard, without a label. + /// + /// # Errors + /// + /// Returns [`crate::Error::PluginInvoke`] if the underlying mobile plugin call fails. pub fn write_text<'a, T: Into>>(&self, text: T) -> crate::Result<()> { let text = text.into().to_string(); self.0 @@ -41,6 +46,13 @@ impl Clipboard { .map_err(Into::into) } + /// Writes plain text to the system clipboard along with a label describing the content. + /// + /// The label is only used on Android (it becomes the `ClipData` label); iOS ignores it. + /// + /// # Errors + /// + /// Returns [`crate::Error::PluginInvoke`] if the underlying mobile plugin call fails. pub fn write_text_with_label<'a, T: Into>>( &self, text: T, @@ -59,12 +71,22 @@ impl Clipboard { .map_err(Into::into) } + /// Not supported on mobile. + /// + /// # Errors + /// + /// Always returns [`crate::Error::Clipboard`]. pub fn write_image(&self, _image: &Image<'_>) -> crate::Result<()> { Err(crate::Error::Clipboard( "Unsupported on this platform".to_string(), )) } + /// Reads the system clipboard as plain text. + /// + /// # Errors + /// + /// Returns [`crate::Error::PluginInvoke`] if the underlying mobile plugin call fails. pub fn read_text(&self) -> crate::Result { self.0 .run_mobile_plugin("readText", ()) @@ -74,6 +96,11 @@ impl Clipboard { .map_err(Into::into) } + /// Not supported on mobile. + /// + /// # Errors + /// + /// Always returns [`crate::Error::Clipboard`]. pub fn read_image(&self) -> crate::Result> { Err(crate::Error::Clipboard( "Unsupported on this platform".to_string(), @@ -81,6 +108,11 @@ impl Clipboard { } // Treat HTML as unsupported on mobile until tested + /// Not supported on mobile. + /// + /// # Errors + /// + /// Always returns [`crate::Error::Clipboard`]. pub fn write_html<'a, T: Into>>( &self, _html: T, @@ -91,6 +123,14 @@ impl Clipboard { )) } + /// Clears the system clipboard. + /// + /// On Android this only works on SDK 28 and above; on older versions the clipboard is + /// instead overwritten with an empty string. + /// + /// # Errors + /// + /// Returns [`crate::Error::PluginInvoke`] if the underlying mobile plugin call fails. pub fn clear(&self) -> crate::Result<()> { self.0.run_mobile_plugin("clear", ()).map_err(Into::into) } diff --git a/plugins/deep-link/guest-js/index.ts b/plugins/deep-link/guest-js/index.ts index e766a4547..41b2fe882 100644 --- a/plugins/deep-link/guest-js/index.ts +++ b/plugins/deep-link/guest-js/index.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Set your Tauri application as the default handler for a URL, or check which URL(s) it was opened with. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' import { type UnlistenFn, listen } from '@tauri-apps/api/event' @@ -17,9 +23,10 @@ import { type UnlistenFn, listen } from '@tauri-apps/api/event' * #### Platform-specific * * - **Windows / Linux:** This function reads the command line arguments and checks if there's only one value, which must be an URL with scheme matching one of the configured values. - * Note that you must manually check the arguments when registering deep link schemes dynamically with [`Self::register`]. + * Note that you must manually check the arguments when registering deep link schemes dynamically with {@link register}. * Additionally, the deep link might have been provided as a CLI argument so you should check if its format matches what you expect. * + * @returns A promise resolving to the list of URLs that triggered the deep link, or `null` if the app was not started via a deep link. * @since 2.0.0 */ export async function getCurrent(): Promise { @@ -41,6 +48,7 @@ export async function getCurrent(): Promise { * * - **macOS / Android / iOS:** Unsupported. * + * @returns A promise that resolves once the protocol has been registered. * @since 2.0.0 */ export async function register(protocol: string): Promise { @@ -60,8 +68,11 @@ export async function register(protocol: string): Promise { * * #### Platform-specific * - * - **macOS / Linux / Android / iOS:** Unsupported. + * - **Windows:** Requires admin rights if the protocol is registered on the local machine (this can happen when registered from the NSIS installer when the install mode is set to both or per machine). + * - **Linux:** Can only unregister the scheme if it was initially registered with {@link register}. May not work on older distros. + * - **macOS / Android / iOS:** Unsupported. * + * @returns A promise that resolves once the protocol has been unregistered. * @since 2.0.0 */ export async function unregister(protocol: string): Promise { @@ -83,6 +94,7 @@ export async function unregister(protocol: string): Promise { * * - **macOS / Android / iOS:** Unsupported. * + * @returns A promise resolving to `true` if the app is the default handler for the protocol, `false` otherwise. * @since 2.0.0 */ export async function isRegistered(protocol: string): Promise { @@ -90,9 +102,9 @@ export async function isRegistered(protocol: string): Promise { } /** - * Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running. Use `getCurrent` on app load to check whether your app was started via a deep link. + * Helper function for the `deep-link://new-url` event to run a function each time the protocol is triggered while the app is running. Use {@link getCurrent} on app load to check whether your app was started via a deep link. * - * @param protocol The name of the protocol without `://`. + * @param handler The function to call with the list of URLs the app was requested to open, every time this happens while the app is running. * * @example * ```typescript @@ -104,6 +116,7 @@ export async function isRegistered(protocol: string): Promise { * * - **Windows / Linux:** Unsupported without the single-instance plugin. The OS will spawn a new app instance passing the URL as a CLI argument. * + * @returns A promise resolving to a function that unregisters the event listener. * @since 2.0.0 */ export async function onOpenUrl( diff --git a/plugins/deep-link/src/error.rs b/plugins/deep-link/src/error.rs index 41eb764f1..7832995fe 100644 --- a/plugins/deep-link/src/error.rs +++ b/plugins/deep-link/src/error.rs @@ -4,25 +4,38 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; +/// The error type for this plugin. #[derive(Debug, thiserror::Error)] pub enum Error { + /// The requested operation (usually registering or unregistering a protocol scheme at + /// runtime) is not supported on the current platform. #[error("unsupported platform")] UnsupportedPlatform, + /// Transparent wrapper around an [`std::io::Error`]. #[error(transparent)] Io(#[from] std::io::Error), + /// Transparent wrapper around a [`tauri::Error`]. #[error(transparent)] Tauri(#[from] tauri::Error), + /// Transparent wrapper around a [`windows_result::Error`]. Only used on Windows. #[cfg(target_os = "windows")] #[error(transparent)] Windows(#[from] windows_result::Error), + /// Transparent wrapper around an [`ini::Error`], returned when reading or writing the + /// `.desktop` file used to register a protocol scheme. Only used on Linux. #[cfg(target_os = "linux")] #[error(transparent)] Ini(#[from] ini::Error), + /// Transparent wrapper around an [`ini::ParseError`], returned when parsing the + /// `.desktop` file used to register a protocol scheme. Only used on Linux. #[cfg(target_os = "linux")] #[error(transparent)] ParseIni(#[from] ini::ParseError), + /// Transparent wrapper around a [`tauri::plugin::mobile::PluginInvokeError`], returned + /// when the underlying mobile plugin invocation fails. Only used on mobile. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), diff --git a/plugins/deep-link/src/lib.rs b/plugins/deep-link/src/lib.rs index 55476bd78..eb5443159 100644 --- a/plugins/deep-link/src/lib.rs +++ b/plugins/deep-link/src/lib.rs @@ -2,6 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Set your Tauri application as the default handler for a URL, or check which URL(s) it was +//! opened with. +//! +//! On Windows and Linux, protocol schemes can additionally be registered and unregistered at +//! runtime with [`DeepLink::register`] and [`DeepLink::unregister`]. On macOS, Android and iOS +//! the schemes declared in the Tauri configuration are registered at build time instead, so +//! calling those methods returns [`Error::UnsupportedPlatform`]. + use tauri::{ plugin::{Builder, PluginApi, TauriPlugin}, AppHandle, EventId, Listener, Manager, Runtime, @@ -479,6 +487,7 @@ use url::Url; /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the deep-link APIs. pub trait DeepLinkExt { + /// Returns a reference to the [`DeepLink`] API. fn deep_link(&self) -> &DeepLink; } diff --git a/plugins/dialog/guest-js/index.ts b/plugins/dialog/guest-js/index.ts index 90d6b26ae..2ca57810a 100644 --- a/plugins/dialog/guest-js/index.ts +++ b/plugins/dialog/guest-js/index.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Native system dialogs for opening and saving files, along with message, ask and confirm dialogs. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' /** @@ -10,7 +16,7 @@ import { invoke } from '@tauri-apps/api/core' * @since 2.0.0 */ interface DialogFilter { - /** Filter name. */ + /** The name of the filter, shown to the user in the dialog's filter selector. */ name: string /** * Extensions to filter, without a `.` prefix. @@ -171,11 +177,11 @@ type BanExcept = Partial< * @since 2.4.0 */ export type MessageDialogButtonsYesNoCancel = { - /** The Yes button. */ + /** The label of the Yes button. */ yes: string - /** The No button. */ + /** The label of the No button. */ no: string - /** The Cancel button. */ + /** The label of the Cancel button. */ cancel: string } & BanExcept<'yes' | 'no' | 'cancel'> @@ -185,9 +191,9 @@ export type MessageDialogButtonsYesNoCancel = { * @since 2.4.0 */ export type MessageDialogButtonsOkCancel = { - /** The Ok button. */ + /** The label of the Ok button. */ ok: string - /** The Cancel button. */ + /** The label of the Cancel button. */ cancel: string } & BanExcept<'ok' | 'cancel'> @@ -197,7 +203,7 @@ export type MessageDialogButtonsOkCancel = { * @since 2.4.0 */ export type MessageDialogButtonsOk = { - /** The Ok button. */ + /** The label of the Ok button. */ ok: string } & BanExcept<'ok'> @@ -221,6 +227,8 @@ export type MessageDialogButtons = | MessageDialogCustomButtons /** + * Options for the message dialog. + * * @since 2.0.0 */ interface MessageDialogOptions { @@ -282,6 +290,11 @@ function buttonsToRust(buttons: MessageDialogButtons | undefined) { return undefined } +/** + * Options for the {@linkcode ask} and {@linkcode confirm} dialogs. + * + * @since 2.0.0 + */ interface ConfirmDialogOptions { /** The title of the dialog. Defaults to the app name. */ title?: string @@ -293,6 +306,12 @@ interface ConfirmDialogOptions { cancelLabel?: string } +/** + * The return type of {@linkcode open}, derived from its options: a single path, an array of + * paths when {@linkcode OpenDialogOptions.multiple} is `true`, or `null` when the user cancels. + * + * @since 2.0.0 + */ type OpenDialogReturn = T['directory'] extends true ? T['multiple'] extends true ? string[] | null @@ -349,6 +368,8 @@ type OpenDialogReturn = T['directory'] extends true * } * ``` * + * @param options The dialog's options. + * * @returns A promise resolving to the selected path(s) * * @since 2.0.0 @@ -383,6 +404,8 @@ async function open( * }); * ``` * + * @param options The dialog's options. + * * @returns A promise resolving to the selected path. * * @since 2.0.0 diff --git a/plugins/dialog/guest-js/init.ts b/plugins/dialog/guest-js/init.ts index 520a469a7..f272c1bd5 100644 --- a/plugins/dialog/guest-js/init.ts +++ b/plugins/dialog/guest-js/init.ts @@ -2,6 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Injected script that replaces `window.alert` and `window.confirm` with implementations backed + * by native dialogs. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' window.alert = function (message: string) { diff --git a/plugins/dialog/src/error.rs b/plugins/dialog/src/error.rs index 0c3ed5b86..dac05b179 100644 --- a/plugins/dialog/src/error.rs +++ b/plugins/dialog/src/error.rs @@ -4,21 +4,28 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for `Result` used throughout this crate. pub type Result = std::result::Result; +/// Errors that can occur while showing or interacting with a dialog. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// An error forwarded from the Tauri core. #[error(transparent)] Tauri(#[from] tauri::Error), + /// An I/O error, for example while resolving a picked path. #[error(transparent)] Io(#[from] std::io::Error), + /// Forwarding a request to, or receiving a response from, the mobile plugin failed. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The folder picker was requested through the `open` command, but folder picking is not implemented on mobile. #[cfg(mobile)] #[error("Folder picker is not implemented on mobile")] FolderPickerNotImplemented, + /// An error forwarded from the `fs` plugin, returned when granting filesystem scope to a picked path fails. #[error(transparent)] Fs(#[from] tauri_plugin_fs::Error), } diff --git a/plugins/dialog/src/lib.rs b/plugins/dialog/src/lib.rs index cfb344509..024df8ce8 100644 --- a/plugins/dialog/src/lib.rs +++ b/plugins/dialog/src/lib.rs @@ -49,19 +49,28 @@ pub use desktop::Dialog; #[cfg(mobile)] pub use mobile::Dialog; +/// The preferred mode of the file picker on mobile platforms (iOS and Android), which have +/// distinct file and media pickers. On desktop, this option is ignored. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum PickerMode { + /// Show the generic document picker. Document, + /// Show the media picker, allowing both images and videos to be selected. Media, + /// Show the media picker restricted to images. Image, + /// Show the media picker restricted to videos. Video, } +/// The file access mode of the dialog, used to control how a picked file is exposed to the app on iOS. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum FileAccessMode { + /// Copy the picked file into the app's sandbox so it can be freely read, edited or deleted. Copy, + /// Keep the file at its original location and let the system manage security-scoped access to it. Scoped, } @@ -86,6 +95,7 @@ macro_rules! blocking_fn { /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the dialog APIs. pub trait DialogExt { + /// Returns the [`Dialog`] instance associated with this app/window. fn dialog(&self) -> &Dialog; } diff --git a/plugins/dialog/src/models.rs b/plugins/dialog/src/models.rs index 330388f8e..2c4f6ec78 100644 --- a/plugins/dialog/src/models.rs +++ b/plugins/dialog/src/models.rs @@ -69,11 +69,16 @@ pub enum MessageDialogButtons { /// Result of a message dialog #[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub enum MessageDialogResult { + /// The user pressed the `Yes` button. Yes, + /// The user pressed the `No` button. No, + /// The user pressed the `Ok` button. Ok, + /// The user pressed the `Cancel` button, or closed the dialog without pressing a button. #[default] Cancel, + /// The user pressed a button with a custom label, holding that label's text. #[serde(untagged)] Custom(String), } diff --git a/plugins/fs/guest-js/index.ts b/plugins/fs/guest-js/index.ts index 1bbe0ec6d..2bea26943 100644 --- a/plugins/fs/guest-js/index.ts +++ b/plugins/fs/guest-js/index.ts @@ -88,9 +88,15 @@ import { BaseDirectory } from '@tauri-apps/api/path' import { Channel, invoke, Resource } from '@tauri-apps/api/core' +/** + * Defines how the offset given to {@linkcode FileHandle.seek} is interpreted. + */ enum SeekMode { + /** The offset is relative to the start of the file. */ Start = 0, + /** The offset is relative to the current cursor position. */ Current = 1, + /** The offset is relative to the end of the file. */ End = 2 } @@ -322,6 +328,8 @@ class FileHandle extends Resource { * await file.close(); * ``` * + * @param buffer The buffer the file contents are read into. + * @returns A promise resolving to the number of bytes read, or `null` when the end of the file was reached. * @since 2.0.0 */ async read(buffer: Uint8Array): Promise { @@ -377,6 +385,9 @@ class FileHandle extends Resource { * await file.close(); * ``` * + * @param offset The number of bytes the cursor is moved by. + * @param whence Defines the position the `offset` is relative to. + * @returns A promise resolving to the new cursor position, relative to the start of the file. * @since 2.0.0 */ async seek(offset: number, whence: SeekMode): Promise { @@ -399,6 +410,7 @@ class FileHandle extends Resource { * await file.close(); * ``` * + * @returns A promise resolving to the metadata of this file. * @since 2.0.0 */ async stat(): Promise { @@ -431,6 +443,7 @@ class FileHandle extends Resource { * await file.close(); * ``` * + * @param len The length the file is truncated or extended to, in bytes. When not provided the entire file contents are truncated. * @since 2.0.0 */ async truncate(len?: number): Promise { @@ -458,6 +471,8 @@ class FileHandle extends Resource { * await file.close(); * ``` * + * @param data The bytes written to the file. + * @returns A promise resolving to the number of bytes written. * @since 2.0.0 */ async write(data: Uint8Array): Promise { @@ -469,6 +484,8 @@ class FileHandle extends Resource { } /** + * Options for the `create` function, which creates or truncates a file. + * * @since 2.0.0 */ interface CreateOptions { @@ -488,6 +505,9 @@ interface CreateOptions { * await file.close(); * ``` * + * @param path The path of the file, relative to `options.baseDir` when it is provided. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the handle of the created file. * @since 2.0.0 */ async function create( @@ -507,6 +527,8 @@ async function create( } /** + * Options for the `open` function, defining how the file is opened and which operations are allowed on it. + * * @since 2.0.0 */ interface OpenOptions { @@ -573,6 +595,9 @@ interface OpenOptions { * await file.close(); * ``` * + * @param path The path of the file, relative to `options.baseDir` when it is provided. + * @param options Options defining the base directory of `path` and how the file is opened. + * @returns A promise resolving to the handle of the open file. * @since 2.0.0 */ async function open( @@ -592,6 +617,8 @@ async function open( } /** + * Options for the `copyFile` function, defining the base directory of each path. + * * @since 2.0.0 */ interface CopyFileOptions { @@ -609,6 +636,9 @@ interface CopyFileOptions { * await copyFile('app.conf', 'app.conf.bk', { fromPathBaseDir: BaseDirectory.AppConfig, toPathBaseDir: BaseDirectory.AppConfig }); * ``` * + * @param fromPath The path of the file to copy from. + * @param toPath The path of the file to copy to. + * @param options Options defining the base directory of each path. * @since 2.0.0 */ async function copyFile( @@ -631,6 +661,8 @@ async function copyFile( } /** + * Options for the `mkdir` function, which creates a directory. + * * @since 2.0.0 */ interface MkdirOptions { @@ -652,6 +684,8 @@ interface MkdirOptions { * await mkdir('users', { baseDir: BaseDirectory.AppLocalData }); * ``` * + * @param path The path of the directory to create. + * @param options Options defining the base directory of `path`, the directory permissions and whether intermediate directories are created. * @since 2.0.0 */ async function mkdir( @@ -669,6 +703,8 @@ async function mkdir( } /** + * Options for the `readDir` function, which lists the entries of a directory. + * * @since 2.0.0 */ interface ReadDirOptions { @@ -700,20 +736,23 @@ interface DirEntry { * ```typescript * import { readDir, BaseDirectory } from '@tauri-apps/plugin-fs'; * import { join } from '@tauri-apps/api/path'; - * const dir = "users" - * const entries = await readDir('users', { baseDir: BaseDirectory.AppLocalData }); - * processEntriesRecursively(dir, entries); + * const dir = 'users'; + * const entries = await readDir(dir, { baseDir: BaseDirectory.AppLocalData }); + * await processEntriesRecursively(dir, entries); * async function processEntriesRecursively(parent, entries) { * for (const entry of entries) { * console.log(`Entry: ${entry.name}`); * if (entry.isDirectory) { - * const dir = await join(parent, entry.name); - * processEntriesRecursively(dir, await readDir(dir, { baseDir: BaseDirectory.AppLocalData })) + * const entryPath = await join(parent, entry.name); + * await processEntriesRecursively(entryPath, await readDir(entryPath, { baseDir: BaseDirectory.AppLocalData })); * } * } * } * ``` * + * @param path The path of the directory to read. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the list of entries in the directory. * @since 2.0.0 */ async function readDir( @@ -731,6 +770,8 @@ async function readDir( } /** + * Options for the functions that read a file, such as `readFile` and `readTextFile`. + * * @since 2.0.0 */ interface ReadFileOptions { @@ -749,6 +790,9 @@ interface ReadFileOptions { * const contents = await readFile('avatar.png', { baseDir: BaseDirectory.Resource }); * ``` * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the contents of the file as bytes. * @since 2.0.0 */ async function readFile( @@ -775,6 +819,9 @@ async function readFile( * const contents = await readTextFile('app.conf', { baseDir: BaseDirectory.AppConfig }); * ``` * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path` and the text encoding. + * @returns A promise resolving to the contents of the file as a string. * @since 2.0.0 */ async function readTextFile( @@ -808,6 +855,9 @@ async function readTextFile( * You could also call {@linkcode AsyncIterableIterator.next} to advance the * iterator so you can lazily read the next line whenever you want. * + * @param path The path of the file to read. + * @param options Options defining the base directory of `path` and the text encoding. + * @returns A promise resolving to an iterator over the lines of the file. * @since 2.0.0 */ async function readTextFileLines( @@ -873,6 +923,8 @@ async function readTextFileLines( } /** + * Options for the `remove` function, which deletes a file or a directory. + * * @since 2.0.0 */ interface RemoveOptions { @@ -892,6 +944,8 @@ interface RemoveOptions { * await remove('users', { baseDir: BaseDirectory.AppLocalData }); * ``` * + * @param path The path of the file or directory to remove. + * @param options Options defining the base directory of `path` and whether directories are removed recursively. * @since 2.0.0 */ async function remove( @@ -909,6 +963,8 @@ async function remove( } /** + * Options for the `rename` function, defining the base directory of each path. + * * @since 2.0.0 */ interface RenameOptions { @@ -931,6 +987,9 @@ interface RenameOptions { * await rename('avatar.png', 'deleted.png', { oldPathBaseDir: BaseDirectory.App, newPathBaseDir: BaseDirectory.AppLocalData }); * ``` * + * @param oldPath The path of the file or directory to rename. + * @param newPath The path the file or directory is renamed to. + * @param options Options defining the base directory of each path. * @since 2.0.0 */ async function rename( @@ -953,6 +1012,8 @@ async function rename( } /** + * Options for the `stat` and `lstat` functions, which read the metadata of a path. + * * @since 2.0.0 */ interface StatOptions { @@ -971,6 +1032,9 @@ interface StatOptions { * console.log(fileInfo.isFile); // true * ``` * + * @param path The path of the file or directory to inspect. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the metadata of the file or directory. * @since 2.0.0 */ async function stat( @@ -997,6 +1061,9 @@ async function stat( * console.log(fileInfo.isFile); // true * ``` * + * @param path The path of the file, directory or symlink to inspect. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to the metadata of the path itself. * @since 2.0.0 */ async function lstat( @@ -1012,6 +1079,8 @@ async function lstat( } /** + * Options for the `truncate` function, which truncates or extends a file. + * * @since 2.0.0 */ interface TruncateOptions { @@ -1037,6 +1106,9 @@ interface TruncateOptions { * console.log(data); // "Hello W" * ``` * + * @param path The path of the file to truncate or extend. + * @param len The length the file is resized to, in bytes. Defaults to `0`. + * @param options Options defining the base directory of `path`. * @since 2.0.0 */ async function truncate( @@ -1056,6 +1128,8 @@ async function truncate( } /** + * Options for the `writeFile` and `writeTextFile` functions, defining how the file is opened before writing to it. + * * @since 2.0.0 */ interface WriteFileOptions { @@ -1082,6 +1156,9 @@ interface WriteFileOptions { * await writeFile('file.txt', data, { baseDir: BaseDirectory.AppLocalData }); * ``` * + * @param path The path of the file to write to. + * @param data The bytes written to the file, either as a buffer or as a stream of chunks. + * @param options Options defining the base directory of `path` and how the file is opened. * @since 2.0.0 */ async function writeFile( @@ -1123,16 +1200,20 @@ async function writeFile( } /** - * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. - @example - * ```typescript - * import { writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; - * - * await writeTextFile('file.txt', "Hello world", { baseDir: BaseDirectory.AppLocalData }); - * ``` - * - * @since 2.0.0 - */ + * Writes UTF-8 string `data` to the given `path`, by default creating a new file if needed, else overwriting. + * + * @example + * ```typescript + * import { writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * await writeTextFile('file.txt', "Hello world", { baseDir: BaseDirectory.AppLocalData }); + * ``` + * + * @param path The path of the file to write to. + * @param data The UTF-8 string written to the file. + * @param options Options defining the base directory of `path` and how the file is opened. + * @since 2.0.0 + */ async function writeTextFile( path: string | URL, data: string, @@ -1153,6 +1234,8 @@ async function writeTextFile( } /** + * Options for the `exists` function, which checks whether a path exists. + * * @since 2.0.0 */ interface ExistsOptions { @@ -1169,6 +1252,9 @@ interface ExistsOptions { * await exists('avatar.png', { baseDir: BaseDirectory.AppData }); * ``` * + * @param path The path to check. + * @param options Options defining the base directory of `path`. + * @returns A promise resolving to `true` when the path exists, `false` otherwise. * @since 2.0.0 */ async function exists( @@ -1186,6 +1272,8 @@ async function exists( } /** + * Options for the `watchImmediate` function, which reports file system changes as they happen. + * * @since 2.0.0 */ interface WatchOptions { @@ -1196,23 +1284,35 @@ interface WatchOptions { } /** + * Options for the `watch` function, which reports file system changes after a debounce delay. + * * @since 2.0.0 */ interface DebouncedWatchOptions extends WatchOptions { - /** Debounce delay */ + /** + * The debounce delay in milliseconds. Changes that happen within this + * window are grouped and reported together. Defaults to `2000`. + */ delayMs?: number } /** + * A file system change reported to the callback of `watch` or `watchImmediate`. + * * @since 2.0.0 */ interface WatchEvent { + /** The kind of change that was detected. */ type: WatchEventKind + /** The paths affected by the change. */ paths: string[] + /** Additional attributes reported by the underlying file system watcher. */ attrs: unknown } /** + * The kind of file system change described by a `WatchEvent`. + * * @since 2.0.0 */ type WatchEventKind = @@ -1224,6 +1324,8 @@ type WatchEventKind = | 'other' /** + * Describes how a file or directory was accessed. + * * @since 2.0.0 */ type WatchEventKindAccess = @@ -1233,6 +1335,8 @@ type WatchEventKindAccess = | { kind: 'other' } /** + * Describes which kind of entry was created. + * * @since 2.0.0 */ type WatchEventKindCreate = @@ -1242,6 +1346,8 @@ type WatchEventKindCreate = | { kind: 'other' } /** + * Describes what was modified on a file or directory. + * * @since 2.0.0 */ type WatchEventKindModify = @@ -1262,6 +1368,8 @@ type WatchEventKindModify = | { kind: 'other' } /** + * Describes which kind of entry was removed. + * * @since 2.0.0 */ type WatchEventKindRemove = @@ -1272,6 +1380,8 @@ type WatchEventKindRemove = // TODO: Remove this in v3, return `Watcher` instead /** + * Stops watching the paths it was created for. Returned by `watch` and `watchImmediate`. + * * @since 2.0.0 */ type UnwatchFn = () => void @@ -1311,6 +1421,27 @@ async function watchInternal( /** * Watch changes (after a delay) on files or directories. * + * Events that happen within the `delayMs` window are grouped and delivered in a single callback call. + * Requires the `watch` Cargo feature of the Rust plugin to be enabled. + * + * @example + * ```typescript + * import { watch, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * const unwatch = await watch( + * 'app.conf', + * (event) => console.log(event.type, event.paths), + * { baseDir: BaseDirectory.AppConfig, delayMs: 500 } + * ); + * + * // stop watching when you are done + * unwatch(); + * ``` + * + * @param paths The path or list of paths to watch. Each path can be a string or a `file://` URL. + * @param cb The callback executed for each batch of file system changes. + * @param options Options defining the base directory of the paths, the debounce delay and whether directories are watched recursively. + * @returns A promise resolving to a function that stops watching the given paths. * @since 2.0.0 */ async function watch( @@ -1328,6 +1459,27 @@ async function watch( /** * Watch changes on files or directories. * + * Unlike `watch`, changes are reported as soon as they are detected, without a debounce delay. + * Requires the `watch` Cargo feature of the Rust plugin to be enabled. + * + * @example + * ```typescript + * import { watchImmediate, BaseDirectory } from '@tauri-apps/plugin-fs'; + * + * const unwatch = await watchImmediate( + * 'logs', + * (event) => console.log(event.type, event.paths), + * { baseDir: BaseDirectory.AppLog, recursive: true } + * ); + * + * // stop watching when you are done + * unwatch(); + * ``` + * + * @param paths The path or list of paths to watch. Each path can be a string or a `file://` URL. + * @param cb The callback executed for each file system change. + * @param options Options defining the base directory of the paths and whether directories are watched recursively. + * @returns A promise resolving to a function that stops watching the given paths. * @since 2.0.0 */ async function watchImmediate( @@ -1354,6 +1506,8 @@ async function watchImmediate( * console.log(dirSize); // 1024 * ``` * + * @param path The path of the file or directory to measure. + * @returns A promise resolving to the size in bytes. * @since 2.1.0 */ async function size(path: string | URL): Promise { @@ -1390,6 +1544,7 @@ async function size(path: string | URL): Promise { * // ... use the resource ... * ``` * + * @param path The path or `file://` URL of the resource to start accessing. * @since 2.5.0 */ async function startAccessingSecurityScopedResource( @@ -1425,6 +1580,7 @@ async function startAccessingSecurityScopedResource( * await stopAccessingSecurityScopedResource(filePath); * ``` * + * @param path The path or `file://` URL of the resource to stop accessing. * @since 2.5.0 */ async function stopAccessingSecurityScopedResource( diff --git a/plugins/fs/src/android.rs b/plugins/fs/src/android.rs index 54cd22ef5..d00b4fc52 100644 --- a/plugins/fs/src/android.rs +++ b/plugins/fs/src/android.rs @@ -9,6 +9,12 @@ use crate::{models::*, FilePath, OpenOptions}; const PLUGIN_IDENTIFIER: &str = "com.plugin.fs"; +/// Access to the file system APIs on Android. +/// +/// In addition to regular file system paths, it can read `content://` URIs +/// and Android asset paths by resolving them with the Android plugin implementation. +/// +/// Retrieved with [`crate::FsExt::fs`]. pub struct Fs(tauri::plugin::PluginHandle); pub fn init( diff --git a/plugins/fs/src/desktop.rs b/plugins/fs/src/desktop.rs index 1dc77fd70..68baa2cdc 100644 --- a/plugins/fs/src/desktop.rs +++ b/plugins/fs/src/desktop.rs @@ -8,6 +8,9 @@ use tauri::{AppHandle, Runtime}; use crate::{FilePath, OpenOptions}; +/// Access to the file system APIs on desktop. +/// +/// Retrieved with [`crate::FsExt::fs`]. pub struct Fs(pub(crate) AppHandle); fn path_or_err>(p: P) -> std::io::Result { diff --git a/plugins/fs/src/error.rs b/plugins/fs/src/error.rs index 0c98e83fc..cb9685fcb 100644 --- a/plugins/fs/src/error.rs +++ b/plugins/fs/src/error.rs @@ -6,15 +6,20 @@ use std::path::PathBuf; use serde::{Serialize, Serializer}; +/// Errors that can happen while using the file system plugin. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// JSON serialization or deserialization error. #[error(transparent)] Json(#[from] serde_json::Error), + /// Error from the Tauri APIs, usually raised while resolving a path or a scope entry. #[error(transparent)] Tauri(#[from] tauri::Error), + /// Error from the underlying file system operation. #[error(transparent)] Io(#[from] std::io::Error), + /// The path is denied by the plugin scope or is not allowed by it. #[error("forbidden path: {0}")] PathForbidden(PathBuf), /// Invalid glob pattern. @@ -24,11 +29,14 @@ pub enum Error { #[cfg(feature = "watch")] #[error(transparent)] Watch(#[from] notify::Error), + /// Error invoking the Android plugin implementation. #[cfg(target_os = "android")] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The URL cannot be converted to a file system path. #[error("URL is not a valid path")] InvalidPathUrl, + /// The path is not safe to use, for instance because it traverses parent directories. #[error("Unsafe PathBuf: {0}")] UnsafePathBuf(&'static str), } diff --git a/plugins/fs/src/ios.rs b/plugins/fs/src/ios.rs index c4a433542..fa8936181 100644 --- a/plugins/fs/src/ios.rs +++ b/plugins/fs/src/ios.rs @@ -7,6 +7,11 @@ use tauri::{plugin::PluginApi, AppHandle, Runtime}; use crate::{FilePath, OpenOptions}; +/// Access to the file system APIs on iOS. +/// +/// Opening a `file://` URL automatically starts accessing the matching security-scoped resource. +/// +/// Retrieved with [`crate::FsExt::fs`]. pub struct Fs { _phantom: std::marker::PhantomData R>, } diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index 61579bef5..9b37604ba 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -58,6 +58,13 @@ pub use file_path::SafeFilePath; type Result = std::result::Result; +/// Options and flags which can be used to configure how a file is opened. +/// +/// This builder exposes the ability to configure how a [`std::fs::File`] is opened and +/// what operations are permitted on the open file. Build it with [`OpenOptions::new`], +/// chain calls to the setter methods and pass it to [`Fs::open`]. +/// +/// The `read` option defaults to `true`, every other option defaults to `false`. #[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenOptions { @@ -199,12 +206,14 @@ impl OpenOptions { /// This function doesn't create the file if it doesn't exist. Use the /// [`OpenOptions::create`] method to do so. /// - /// [`write()`]: Write::write "io::Write::write" - /// [`flush()`]: Write::flush "io::Write::flush" - /// [stream_position]: Seek::stream_position "io::Seek::stream_position" - /// [seek]: Seek::seek "io::Seek::seek" - /// [Current]: SeekFrom::Current "io::SeekFrom::Current" - /// [End]: SeekFrom::End "io::SeekFrom::End" + /// [`write()`]: std::io::Write::write "io::Write::write" + /// [`flush()`]: std::io::Write::flush "io::Write::flush" + /// [Seek]: std::io::Seek "io::Seek" + /// [stream_position]: std::io::Seek::stream_position "io::Seek::stream_position" + /// [seek]: std::io::Seek::seek "io::Seek::seek" + /// [SeekFrom]: std::io::SeekFrom "io::SeekFrom" + /// [Current]: std::io::SeekFrom::Current "io::SeekFrom::Current" + /// [End]: std::io::SeekFrom::End "io::SeekFrom::End" /// /// # Examples /// @@ -260,7 +269,7 @@ impl OpenOptions { /// No file is allowed to exist at the target location, also no (dangling) symlink. In this /// way, if the call succeeds, the file returned is guaranteed to be new. /// If a file exists at the target location, creating a new file will fail with [`AlreadyExists`] - /// or another error based on the situation. See [`OpenOptions::open`] for a + /// or another error based on the situation. See [`std::fs::OpenOptions::open`] for a /// non-exhaustive list of likely errors. /// /// This option is useful because it is atomic. Otherwise between checking @@ -275,7 +284,7 @@ impl OpenOptions { /// /// [`.create()`]: OpenOptions::create /// [`.truncate()`]: OpenOptions::truncate - /// [`AlreadyExists`]: io::ErrorKind::AlreadyExists + /// [`AlreadyExists`]: std::io::ErrorKind::AlreadyExists /// /// # Examples /// @@ -328,6 +337,14 @@ impl OpenOptions { } impl Fs { + /// Reads the entire contents of a file into a string. + /// + /// The file is opened in read-only mode with [`Fs::open`]. + /// + /// # Errors + /// + /// Returns an error if `path` cannot be opened for reading or if its + /// contents are not valid UTF-8. pub fn read_to_string>(&self, path: P) -> std::io::Result { let mut s = String::new(); self.open( @@ -341,6 +358,13 @@ impl Fs { Ok(s) } + /// Reads the entire contents of a file into a bytes vector. + /// + /// The file is opened in read-only mode with [`Fs::open`]. + /// + /// # Errors + /// + /// Returns an error if `path` cannot be opened for reading. pub fn read>(&self, path: P) -> std::io::Result> { let mut buf = Vec::new(); self.open( @@ -432,8 +456,37 @@ impl SecurityScopedResources { pub(crate) fn remove(&self, _url: &str) {} } +/// Extension trait implemented by every [`Manager`] (the app handle, windows, webviews, ...) +/// to access the file system plugin APIs. +/// +/// # Examples +/// +/// ```rust,no_run +/// use std::path::Path; +/// use tauri::Runtime; +/// use tauri_plugin_fs::FsExt; +/// +/// fn setup(app: &tauri::App) -> Result<(), Box> { +/// // allow the app to access a directory that is not part of the static scope +/// app.fs_scope().allow_directory(Path::new("/path/to/directory"), true)?; +/// +/// let contents = app.fs().read_to_string(Path::new("/path/to/directory/file.txt"))?; +/// println!("{contents}"); +/// +/// Ok(()) +/// } +/// ``` pub trait FsExt { + /// Returns the file system scope, which can be used to dynamically + /// allow or deny paths at runtime. + /// + /// # Panics + /// + /// Panics if the plugin is not registered in the app. + /// Use [`FsExt::try_fs_scope`] if the plugin might not be registered. fn fs_scope(&self) -> tauri::fs::Scope; + + /// Returns the file system scope, or `None` if the plugin is not registered in the app. fn try_fs_scope(&self) -> Option; /// Cross platform file system APIs that also support manipulating Android files. @@ -454,6 +507,7 @@ impl> FsExt for T { } } +/// Initializes the plugin. pub fn init() -> TauriPlugin> { PluginBuilder::>::new("fs") .invoke_handler(tauri::generate_handler![ diff --git a/plugins/geolocation/guest-js/index.ts b/plugins/geolocation/guest-js/index.ts index 8ef5c5330..2c05bdae2 100644 --- a/plugins/geolocation/guest-js/index.ts +++ b/plugins/geolocation/guest-js/index.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Get and track the device's current position, mirroring the W3C Geolocation API. + * + * @module + */ + import { Channel, invoke, @@ -9,6 +15,9 @@ import { checkPermissions as checkPluginPermissions } from '@tauri-apps/api/core' +/** + * The GPS coordinates of a {@link Position}, along with the accuracy of each reading. + */ export type Coordinates = { /** * Latitude in decimal degrees. @@ -31,6 +40,9 @@ export type Coordinates = { * The altitude the user is at, if available. */ altitude: number | null + /** + * The speed the user is traveling, in meters per second, if available. + */ speed: number | null /** * The heading the user is facing, if available. @@ -38,6 +50,9 @@ export type Coordinates = { heading: number | null } +/** + * The current permission state for the geolocation APIs. + */ export type PermissionStatus = { /** * Permission state for the location alias. @@ -59,19 +74,32 @@ export type PermissionStatus = { coarseLocation: PermissionState } +/** + * The individual permission aliases that can be requested with {@link requestPermissions}. + * + * `location` maps to both the coarse and fine location permissions on Android and to the standard + * location permission on iOS. `coarseLocation` maps to the coarse location permission only on + * Android, and behaves the same as `location` on iOS. + */ export type PermissionType = 'location' | 'coarseLocation' +/** + * A geolocation reading, as returned by {@link getCurrentPosition} and passed to the callback of {@link watchPosition}. + */ export type Position = { /** - * Creation time for these coordinates. + * Creation time for these coordinates, in milliseconds since the Unix epoch. */ timestamp: number /** - * The GPD coordinates along with the accuracy of the data. + * The GPS coordinates along with the accuracy of the data. */ coords: Coordinates } +/** + * Options used to configure a {@link getCurrentPosition} or {@link watchPosition} request. + */ export type PositionOptions = { /** * High accuracy mode (such as GPS, if available) @@ -92,6 +120,29 @@ export type PositionOptions = { maximumAge: number } +/** + * Registers a callback that is invoked with the device's position whenever it changes, similar to the W3C `navigator.geolocation.watchPosition` API. Pass the returned id to {@link clearWatch} to stop watching. + * + * @example + * ```typescript + * import { watchPosition } from '@tauri-apps/plugin-geolocation'; + * const watchId = await watchPosition( + * { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, + * (position, error) => { + * if (error) { + * console.error(error) + * } else { + * console.log(position) + * } + * } + * ); + * ``` + * + * @param options Configuration for the position watcher. + * @param cb Callback invoked with the new {@link Position} on success, or `null` and an error message when a read fails. + * @returns A promise resolving to the id of the registered watcher. + * @since 2.0.0 + */ export async function watchPosition( options: PositionOptions, cb: (location: Position | null, error?: string) => void @@ -111,6 +162,19 @@ export async function watchPosition( return channel.id } +/** + * Returns the device's current position, similar to the W3C `navigator.geolocation.getCurrentPosition` API. + * + * @example + * ```typescript + * import { getCurrentPosition } from '@tauri-apps/plugin-geolocation'; + * const position = await getCurrentPosition(); + * ``` + * + * @param options Configuration for the position request. + * @returns A promise resolving to the current {@link Position}. + * @since 2.0.0 + */ export async function getCurrentPosition( options?: PositionOptions ): Promise { @@ -119,16 +183,53 @@ export async function getCurrentPosition( }) } +/** + * Stops the position watcher registered with {@link watchPosition}. + * + * @example + * ```typescript + * import { clearWatch } from '@tauri-apps/plugin-geolocation'; + * await clearWatch(watchId); + * ``` + * + * @param channelId The id returned by {@link watchPosition}. + * @since 2.0.0 + */ export async function clearWatch(channelId: number): Promise { await invoke('plugin:geolocation|clear_watch', { channelId }) } +/** + * Returns the current state of the geolocation permissions. Rejects if location services are disabled on the device. + * + * @example + * ```typescript + * import { checkPermissions } from '@tauri-apps/plugin-geolocation'; + * const permission = await checkPermissions(); + * ``` + * + * @returns A promise resolving to the current {@link PermissionStatus}. + * @since 2.0.0 + */ export async function checkPermissions(): Promise { return await checkPluginPermissions('geolocation') } +/** + * Requests the given geolocation permissions, prompting the user if needed. Rejects if location services are disabled on the device. + * + * @example + * ```typescript + * import { requestPermissions } from '@tauri-apps/plugin-geolocation'; + * const permission = await requestPermissions(['location']); + * ``` + * + * @param permissions The permissions to request, or `null` to request all of them. + * @returns A promise resolving to the resulting {@link PermissionStatus}. + * @since 2.0.0 + */ export async function requestPermissions( permissions: PermissionType[] | null ): Promise { diff --git a/plugins/geolocation/src/desktop.rs b/plugins/geolocation/src/desktop.rs index 00da1fadb..3ca41db5f 100644 --- a/plugins/geolocation/src/desktop.rs +++ b/plugins/geolocation/src/desktop.rs @@ -22,6 +22,7 @@ pub fn init( pub struct Geolocation(AppHandle); impl Geolocation { + /// Not implemented on desktop platforms; always resolves to a default, zeroed [`Position`] without reading any real location. pub fn get_current_position( &self, _options: Option, @@ -29,6 +30,7 @@ impl Geolocation { Ok(Position::default()) } + /// Not implemented on desktop platforms. Registers a channel for `channel_id` bookkeeping, but `callback` is never invoked with a real [`WatchEvent`]. pub fn watch_position( &self, options: PositionOptions, @@ -64,14 +66,17 @@ impl Geolocation { Ok(()) } + /// Not implemented on desktop platforms; always succeeds without doing anything. pub fn clear_watch(&self, _channel_id: u32) -> crate::Result<()> { Ok(()) } + /// Not implemented on desktop platforms; always resolves to the default [`PermissionStatus`] (both permissions in the [`Prompt`](tauri::plugin::PermissionState::Prompt) state). pub fn check_permissions(&self) -> crate::Result { Ok(PermissionStatus::default()) } + /// Not implemented on desktop platforms; always resolves to the default [`PermissionStatus`] without prompting the user. pub fn request_permissions( &self, _permissions: Option>, diff --git a/plugins/geolocation/src/error.rs b/plugins/geolocation/src/error.rs index f59037de8..1c4f69517 100644 --- a/plugins/geolocation/src/error.rs +++ b/plugins/geolocation/src/error.rs @@ -4,13 +4,16 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for the result type returned by the geolocation APIs. pub type Result = std::result::Result; // TODO: Improve Error handling (different typed errors instead of one (stringified) PluginInvokeError for all mobile errors) +/// Errors that can occur while interacting with the geolocation APIs. #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "specta", derive(specta::Type))] pub enum Error { + /// Forwards an error returned by the underlying Android or iOS plugin invocation. #[cfg(mobile)] #[error(transparent)] PluginInvoke( diff --git a/plugins/geolocation/src/lib.rs b/plugins/geolocation/src/lib.rs index a1e5d2cea..2c87c13f1 100644 --- a/plugins/geolocation/src/lib.rs +++ b/plugins/geolocation/src/lib.rs @@ -33,6 +33,7 @@ pub use mobile::Geolocation; /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the geolocation APIs. pub trait GeolocationExt { + /// Returns the handle to the geolocation APIs. fn geolocation(&self) -> &Geolocation; } diff --git a/plugins/geolocation/src/mobile.rs b/plugins/geolocation/src/mobile.rs index 48a3f5ded..4de9676a9 100644 --- a/plugins/geolocation/src/mobile.rs +++ b/plugins/geolocation/src/mobile.rs @@ -33,6 +33,7 @@ pub fn init( pub struct Geolocation(PluginHandle); impl Geolocation { + /// Returns the device's current [`Position`]. On Android this returns the last known location immediately if it is still within `maximum_age`, otherwise it requests a fresh reading. Errors if location services are disabled or the required permission was not granted. pub fn get_current_position( &self, options: Option, @@ -81,18 +82,21 @@ impl Geolocation { .map_err(Into::into) } + /// Removes the position watcher registered with the given `channel_id`, as returned by [`Self::watch_position`]. Stops the platform location updates once no watcher remains. pub fn clear_watch(&self, channel_id: u32) -> crate::Result<()> { self.0 .run_mobile_plugin("clearWatch", ClearWatchPayload { channel_id }) .map_err(Into::into) } + /// Returns the current [`PermissionStatus`] for the geolocation APIs. Errors if location services are disabled on the device. pub fn check_permissions(&self) -> crate::Result { self.0 .run_mobile_plugin("checkPermissions", ()) .map_err(Into::into) } + /// Requests the given `permissions` (or all of them when `None`) and returns the resulting [`PermissionStatus`]. Errors if location services are disabled on the device. pub fn request_permissions( &self, permissions: Option>, diff --git a/plugins/geolocation/src/models.rs b/plugins/geolocation/src/models.rs index c08bb27a2..9dd7d3ab3 100644 --- a/plugins/geolocation/src/models.rs +++ b/plugins/geolocation/src/models.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use tauri::plugin::PermissionState; +/// The current permission state for the geolocation APIs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] @@ -25,6 +26,7 @@ pub struct PermissionStatus { pub coarse_location: PermissionState, } +/// Options used to configure a [`get_current_position`](crate::Geolocation::get_current_position) or [`watch_position`](crate::Geolocation::watch_position) request. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] @@ -47,14 +49,18 @@ pub struct PositionOptions { pub maximum_age: u32, } +/// The individual permission aliases that can be requested with [`request_permissions`](crate::Geolocation::request_permissions). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub enum PermissionType { + /// The `location` alias. On Android this maps to both `ACCESS_COARSE_LOCATION` and `ACCESS_FINE_LOCATION`. On iOS it maps to the standard location permission. Location, + /// The `coarseLocation` alias. On Android this maps to `ACCESS_COARSE_LOCATION` only. On iOS it behaves the same as [`Location`](Self::Location). CoarseLocation, } +/// The GPS coordinates of a [`Position`], along with the accuracy of each reading. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] @@ -70,12 +76,13 @@ pub struct Coordinates { pub altitude_accuracy: Option, /// The altitude the user is at, if available. pub altitude: Option, - // The speed the user is traveling, if available. + /// The speed the user is traveling, in meters per second, if available. pub speed: Option, /// The heading the user is facing, if available. pub heading: Option, } +/// A geolocation reading, as returned by [`get_current_position`](crate::Geolocation::get_current_position) and reported through [`WatchEvent::Position`]. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] @@ -87,10 +94,13 @@ pub struct Position { pub coords: Coordinates, } +/// A single update sent through the channel callback registered with [`watch_position`](crate::Geolocation::watch_position). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(untagged)] pub enum WatchEvent { + /// A new position was read successfully. Position(Position), + /// The platform failed to read a position; the string is the platform-provided error message. Error(String), } diff --git a/plugins/global-shortcut/guest-js/index.ts b/plugins/global-shortcut/guest-js/index.ts index 13e8e50e8..8f30a6919 100644 --- a/plugins/global-shortcut/guest-js/index.ts +++ b/plugins/global-shortcut/guest-js/index.ts @@ -10,12 +10,19 @@ import { invoke, Channel } from '@tauri-apps/api/core' +/** + * Payload sent to a shortcut handler when a registered shortcut is pressed or released. + */ export interface ShortcutEvent { + /** The shortcut definition that triggered this event, e.g. `CommandOrControl+Shift+C`. */ shortcut: string + /** Numeric identifier derived from the shortcut's modifiers and key. */ id: number + /** Whether the shortcut's key combination was pressed down or released. */ state: 'Released' | 'Pressed' } +/** Callback invoked with a {@link ShortcutEvent} whenever a registered shortcut changes state. */ export type ShortcutHandler = (event: ShortcutEvent) => void /** @@ -43,7 +50,7 @@ export type ShortcutHandler = (event: ShortcutEvent) => void * }); * ``` * - * @param shortcut Shortcut definition, modifiers and key separated by "+" e.g. CmdOrControl+Q + * @param shortcuts A shortcut definition, or a list of shortcut definitions, with modifiers and key separated by "+" e.g. CmdOrControl+Q * @param handler Shortcut handler callback - takes the triggered shortcut as argument * * @since 2.0.0 @@ -75,7 +82,7 @@ async function register( * await unregister(['CmdOrControl+Space', 'Alt+A']); * ``` * - * @param shortcut shortcut definition (modifiers and key separated by "+" e.g. CmdOrControl+Q), also accepts a list of shortcuts + * @param shortcuts A shortcut definition, or a list of shortcut definitions, with modifiers and key separated by "+" e.g. CmdOrControl+Q * * @since 2.0.0 */ @@ -111,6 +118,7 @@ async function unregisterAll(): Promise { * ``` * * @param shortcut shortcut definition, modifiers and key separated by "+" e.g. CmdOrControl+Q + * @returns A promise resolving to whether the shortcut is currently registered by this application. * * @since 2.0.0 */ diff --git a/plugins/global-shortcut/src/error.rs b/plugins/global-shortcut/src/error.rs index 37392b321..9b8e5cbab 100644 --- a/plugins/global-shortcut/src/error.rs +++ b/plugins/global-shortcut/src/error.rs @@ -4,13 +4,20 @@ use serde::{Serialize, Serializer}; +/// Errors that can happen while registering, unregistering or parsing global shortcuts. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// An error returned by the underlying `global_hotkey` crate, for example when the OS + /// refuses to register or unregister a shortcut, or when a shortcut string fails to parse. #[error("{0}")] GlobalHotkey(String), + /// Failed to receive the result of an operation dispatched to the main thread because the + /// sending end of the channel was dropped before it could reply. #[error(transparent)] RecvError(#[from] std::sync::mpsc::RecvError), + /// An error returned by the Tauri runtime, for example when dispatching a closure to run on + /// the main thread fails. #[error(transparent)] Tauri(#[from] tauri::Error), } diff --git a/plugins/global-shortcut/src/lib.rs b/plugins/global-shortcut/src/lib.rs index 450bb5985..19a4fa121 100644 --- a/plugins/global-shortcut/src/lib.rs +++ b/plugins/global-shortcut/src/lib.rs @@ -5,6 +5,8 @@ //! Register global shortcuts. //! //! - Supported platforms: Windows, Linux and macOS. +//! - On macOS, registering and unregistering shortcuts must happen on the main thread, so every +//! [`GlobalShortcut`] operation dispatches to it and blocks until it completes. #![doc( html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", @@ -38,6 +40,9 @@ type Result = std::result::Result; type HotKeyId = u32; type HandlerFn = Box, &Shortcut, ShortcutEvent) + Send + Sync + 'static>; +/// Internal wrapper around a [`Shortcut`] that lets the shortcut-accepting APIs on +/// [`GlobalShortcut`] and [`Builder`] be generic over an already-parsed [`Shortcut`] or a +/// string accelerator such as `"CmdOrControl+Q"`. pub struct ShortcutWrapper(Shortcut); impl From for ShortcutWrapper { @@ -65,6 +70,11 @@ unsafe impl Send for GlobalHotKeyManager {} /// SAFETY: we ensure it is run on main thread only unsafe impl Sync for GlobalHotKeyManager {} +/// The global shortcut APIs, accessible through [`GlobalShortcutExt::global_shortcut`]. +/// +/// Registering, unregistering and checking a shortcut all dispatch the underlying +/// `global_hotkey` call to the app's main thread and block until it finishes, since macOS +/// requires the hotkey manager to run on the main thread. pub struct GlobalShortcut { #[allow(dead_code)] app: AppHandle, @@ -127,7 +137,8 @@ impl GlobalShortcut { } impl GlobalShortcut { - /// Register a shortcut. + /// Register a shortcut. Returns an error if the shortcut is invalid or already registered + /// by this [`GlobalShortcut`] instance. pub fn register(&self, shortcut: S) -> Result<()> where S: TryInto, @@ -139,7 +150,10 @@ impl GlobalShortcut { ) } - /// Register a shortcut with a handler. + /// Register a shortcut with a handler that is called with the app handle, the triggered + /// [`Shortcut`] and the [`ShortcutEvent`] (whose `state` is [`ShortcutState::Pressed`] or + /// [`ShortcutState::Released`]) whenever the shortcut's key combination is pressed or + /// released. pub fn on_shortcut(&self, shortcut: S, handler: F) -> Result<()> where S: TryInto, @@ -241,7 +255,28 @@ impl GlobalShortcut { } } +/// Extension trait for [`Manager`] implementors (e.g. [`AppHandle`]) that exposes access to the +/// global shortcut APIs. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tauri::Runtime; +/// use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +/// +/// fn setup(app: &tauri::App) -> Result<(), Box> { +/// app.global_shortcut() +/// .on_shortcut("CommandOrControl+Shift+C", |_app, shortcut, event| { +/// if event.state == ShortcutState::Pressed { +/// println!("{shortcut:?} pressed"); +/// } +/// })?; +/// +/// Ok(()) +/// } +/// ``` pub trait GlobalShortcutExt { + /// Returns the [`GlobalShortcut`] instance managed by this plugin. fn global_shortcut(&self) -> &GlobalShortcut; } @@ -333,6 +368,26 @@ fn is_registered( Ok(global_shortcut.is_registered(parse_shortcut(shortcut)?)) } +/// Builder for the global shortcut plugin, letting you configure shortcuts and a handler that +/// are registered as soon as the plugin is set up, before [`Builder::build`] is called. +/// +/// # Examples +/// +/// ```rust,no_run +/// use tauri::Runtime; +/// +/// fn setup(builder: tauri::Builder) -> tauri::Builder { +/// builder.plugin( +/// tauri_plugin_global_shortcut::Builder::new() +/// .with_shortcut("CommandOrControl+Shift+C") +/// .unwrap() +/// .with_handler(|_app, shortcut, event| { +/// println!("{shortcut:?}: {event:?}"); +/// }) +/// .build(), +/// ) +/// } +/// ``` pub struct Builder { shortcuts: Vec, handler: Option>, @@ -348,6 +403,7 @@ impl Default for Builder { } impl Builder { + /// Creates a new [`Builder`] with no shortcuts or handler configured. pub fn new() -> Self { Self::default() } @@ -385,6 +441,11 @@ impl Builder { self } + /// Builds the plugin. Shortcuts configured with [`Builder::with_shortcut`] / + /// [`Builder::with_shortcuts`] are registered when the plugin is set up, and the handler + /// configured with [`Builder::with_handler`] (if any) is called for every hotkey event, in + /// addition to any handler passed to [`GlobalShortcut::on_shortcut`] / + /// [`GlobalShortcut::on_shortcuts`] for that specific shortcut. pub fn build(self) -> TauriPlugin { let handler = self.handler; let shortcuts = self.shortcuts; diff --git a/plugins/haptics/api-iife.js b/plugins/haptics/api-iife.js index 857ed2764..0f50b70ca 100644 --- a/plugins/haptics/api-iife.js +++ b/plugins/haptics/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_HAPTICS__=function(t,r){"use strict";const e={async vibrate(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|vibrate",{duration:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async impactFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|impact_feedback",{style:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async notificationFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|notification_feedback",{type:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async selectionFeedback(){try{return{status:"ok",data:await r.invoke("plugin:haptics|selection_feedback")}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}}},{vibrate:a,impactFeedback:i,notificationFeedback:c,selectionFeedback:n}=e;return t.impactFeedback=i,t.notificationFeedback=c,t.selectionFeedback=n,t.vibrate=a,t}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"haptics",{value:__TAURI_PLUGIN_HAPTICS__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_HAPTICS__=function(t,r){"use strict";const e={async vibrate(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|vibrate",{duration:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async impactFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|impact_feedback",{style:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async notificationFeedback(t){try{return{status:"ok",data:await r.invoke("plugin:haptics|notification_feedback",{type:t})}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}},async selectionFeedback(){try{return{status:"ok",data:await r.invoke("plugin:haptics|selection_feedback")}}catch(t){if(t instanceof Error)throw t;return{status:"error",error:t}}}};return t.impactFeedback=async function(t){return e.impactFeedback(t)},t.notificationFeedback=async function(t){return e.notificationFeedback(t)},t.selectionFeedback=async function(){return e.selectionFeedback()},t.vibrate=async function(t){return e.vibrate(t)},t}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"haptics",{value:__TAURI_PLUGIN_HAPTICS__})} diff --git a/plugins/haptics/guest-js/bindings.ts b/plugins/haptics/guest-js/bindings.ts index d12920d85..040e27861 100644 --- a/plugins/haptics/guest-js/bindings.ts +++ b/plugins/haptics/guest-js/bindings.ts @@ -73,12 +73,26 @@ export const commands = { /** user-defined types **/ export type Error = never +/** + * The style of an impact-feedback haptic. + * + * On iOS this maps directly to a `UIImpactFeedbackGenerator.FeedbackStyle` case. On Android, + * which has no equivalent system API, each style instead plays a distinct vibration waveform of + * increasing intensity. Has no effect on desktop platforms. Defaults to `Medium`. + */ export type ImpactFeedbackStyle = | 'light' | 'medium' | 'heavy' | 'soft' | 'rigid' +/** + * The type of notification feedback, indicating the outcome of a task or action. + * + * On iOS this maps directly to a `UINotificationFeedbackGenerator.FeedbackType` case. On + * Android, which has no equivalent system API, each type instead plays a distinct vibration + * waveform. Has no effect on desktop platforms. Defaults to `Success`. + */ export type NotificationFeedbackType = 'success' | 'warning' | 'error' //export type RandomNumber = number; diff --git a/plugins/haptics/guest-js/index.ts b/plugins/haptics/guest-js/index.ts index 23485bdfb..9c477aaba 100644 --- a/plugins/haptics/guest-js/index.ts +++ b/plugins/haptics/guest-js/index.ts @@ -2,16 +2,102 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -/* eslint-disable @typescript-eslint/unbound-method */ +/** + * Trigger haptic feedback on Android and iOS. + * + * @module + */ import { commands } from './bindings' +import type { + ImpactFeedbackStyle, + NotificationFeedbackType, + Result, + Error +} from './bindings' -export const { - vibrate, - impactFeedback, - notificationFeedback, - selectionFeedback -} = commands +/** + * Triggers a vibration for `duration` milliseconds. + * + * Has no effect on desktop platforms. + * + * @example + * ```typescript + * import { vibrate } from '@tauri-apps/plugin-haptics' + * await vibrate(300) + * ``` + * + * @param duration Duration of the vibration, in milliseconds. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function vibrate(duration: number): Promise> { + return commands.vibrate(duration) +} + +/** + * Triggers an impact-feedback haptic, indicating a collision between user interface elements. + * + * On iOS this maps to a `UIImpactFeedbackGenerator` of the given style. On Android, which has + * no equivalent system API, each style plays a distinct vibration waveform. Has no effect on + * desktop platforms. + * + * @example + * ```typescript + * import { impactFeedback } from '@tauri-apps/plugin-haptics' + * await impactFeedback('medium') + * ``` + * + * @param style The style of the impact. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function impactFeedback( + style: ImpactFeedbackStyle +): Promise> { + return commands.impactFeedback(style) +} + +/** + * Triggers a notification-feedback haptic, indicating the outcome of a task or action. + * + * On iOS this maps to a `UINotificationFeedbackGenerator` of the given type. On Android, which + * has no equivalent system API, each type plays a distinct vibration waveform. Has no effect on + * desktop platforms. + * + * @example + * ```typescript + * import { notificationFeedback } from '@tauri-apps/plugin-haptics' + * await notificationFeedback('success') + * ``` + * + * @param type The outcome to convey. + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function notificationFeedback( + type: NotificationFeedbackType +): Promise> { + return commands.notificationFeedback(type) +} + +/** + * Triggers a haptic indicating that a selection changed, e.g. the value of a picker control. + * + * Has no effect on desktop platforms. + * + * @example + * ```typescript + * import { selectionFeedback } from '@tauri-apps/plugin-haptics' + * await selectionFeedback() + * ``` + * + * @returns A promise resolving to the {@link Result} of the operation. + * @since 2.0.0 + */ +export async function selectionFeedback(): Promise> { + return commands.selectionFeedback() +} export { ImpactFeedbackStyle, NotificationFeedbackType } from './bindings' diff --git a/plugins/haptics/src/desktop.rs b/plugins/haptics/src/desktop.rs index b04b75676..d036eddad 100644 --- a/plugins/haptics/src/desktop.rs +++ b/plugins/haptics/src/desktop.rs @@ -18,18 +18,26 @@ pub fn init( pub struct Haptics(AppHandle); impl Haptics { + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. pub fn vibrate(&self, _duration: u32) -> crate::Result<()> { Ok(()) } + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. pub fn impact_feedback(&self, _style: ImpactFeedbackStyle) -> crate::Result<()> { Ok(()) } + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. pub fn notification_feedback(&self, _type: NotificationFeedbackType) -> crate::Result<()> { Ok(()) } + /// No-op on desktop; haptic feedback is not supported on Linux, macOS or Windows. Always + /// returns `Ok`. pub fn selection_feedback(&self) -> crate::Result<()> { Ok(()) } diff --git a/plugins/haptics/src/error.rs b/plugins/haptics/src/error.rs index f59037de8..3a24755e8 100644 --- a/plugins/haptics/src/error.rs +++ b/plugins/haptics/src/error.rs @@ -4,13 +4,18 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for a [`std::result::Result`] with the error type [`Error`]. pub type Result = std::result::Result; // TODO: Improve Error handling (different typed errors instead of one (stringified) PluginInvokeError for all mobile errors) +/// Errors returned by the haptics APIs. #[derive(Debug, thiserror::Error)] #[cfg_attr(feature = "specta", derive(specta::Type))] pub enum Error { + /// The call to the Android or iOS plugin implementation failed, either because the + /// arguments could not be serialized, the response could not be deserialized, or the + /// native side rejected the call. Only produced on Android and iOS. #[cfg(mobile)] #[error(transparent)] PluginInvoke( diff --git a/plugins/haptics/src/lib.rs b/plugins/haptics/src/lib.rs index f297a7488..7984bebb0 100644 --- a/plugins/haptics/src/lib.rs +++ b/plugins/haptics/src/lib.rs @@ -33,6 +33,7 @@ pub use mobile::Haptics; /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the haptics APIs. pub trait HapticsExt { + /// Returns a handle to the [`Haptics`] APIs. fn haptics(&self) -> &Haptics; } diff --git a/plugins/haptics/src/mobile.rs b/plugins/haptics/src/mobile.rs index 2b1e2036c..42ae2459a 100644 --- a/plugins/haptics/src/mobile.rs +++ b/plugins/haptics/src/mobile.rs @@ -32,18 +32,40 @@ pub fn init( pub struct Haptics(PluginHandle); impl Haptics { + /// Triggers a vibration for `duration` milliseconds. + /// + /// On iOS this plays a continuous [Core Haptics](https://developer.apple.com/documentation/corehaptics) + /// pattern when the device supports it, falling back to the system alert vibration + /// otherwise. On Android it uses [`Vibrator`](https://developer.android.com/reference/android/os/Vibrator). + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. pub fn vibrate(&self, duration: u32) -> crate::Result<()> { self.0 .run_mobile_plugin("vibrate", VibratePayload { duration }) .map_err(Into::into) } + /// Triggers an impact-feedback haptic with the given [`ImpactFeedbackStyle`]. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. pub fn impact_feedback(&self, style: ImpactFeedbackStyle) -> crate::Result<()> { self.0 .run_mobile_plugin("impactFeedback", ImpactFeedbackPayload { style }) .map_err(Into::into) } + /// Triggers a notification-feedback haptic for the given [`NotificationFeedbackType`]. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. pub fn notification_feedback(&self, r#type: NotificationFeedbackType) -> crate::Result<()> { self.0 .run_mobile_plugin( @@ -53,6 +75,13 @@ impl Haptics { .map_err(Into::into) } + /// Triggers a haptic indicating that a selection changed, e.g. when the value of a picker + /// control changes. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) if the underlying Android or + /// iOS plugin invocation fails. pub fn selection_feedback(&self) -> crate::Result<()> { self.0 .run_mobile_plugin("selectionFeedback", ()) diff --git a/plugins/haptics/src/models.rs b/plugins/haptics/src/models.rs index 50a1fb163..d59cc2029 100644 --- a/plugins/haptics/src/models.rs +++ b/plugins/haptics/src/models.rs @@ -13,24 +13,42 @@ pub struct HapticsOptions { } */ +/// The style of an impact-feedback haptic. +/// +/// On iOS this maps directly to a `UIImpactFeedbackGenerator.FeedbackStyle` case. On Android, +/// which has no equivalent system API, each style instead plays a distinct vibration waveform of +/// increasing intensity. Has no effect on desktop platforms. Defaults to `Medium`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub enum ImpactFeedbackStyle { + /// A collision between small, light user interface elements. Light, + /// A collision between moderately sized user interface elements. #[default] Medium, + /// A collision between large, heavy user interface elements. Heavy, + /// A soft, muted impact between user interface elements. Soft, + /// A sharp, rigid impact between user interface elements. Rigid, } +/// The type of notification feedback, indicating the outcome of a task or action. +/// +/// On iOS this maps directly to a `UINotificationFeedbackGenerator.FeedbackType` case. On +/// Android, which has no equivalent system API, each type instead plays a distinct vibration +/// waveform. Has no effect on desktop platforms. Defaults to `Success`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub enum NotificationFeedbackType { + /// A task or action has completed successfully. #[default] Success, + /// A task or action has produced a warning. Warning, + /// A task or action has failed. Error, } diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index 5406e03f2..5d4e0945f 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -48,6 +48,11 @@ export interface Proxy { https?: string | ProxyConfig } +/** + * Detailed configuration of a single proxy server, used when a plain URL string is not enough. + * + * @since 2.0.0 + */ export interface ProxyConfig { /** * The URL of the proxy server. @@ -57,7 +62,13 @@ export interface ProxyConfig { * Set the `Proxy-Authorization` header using Basic auth. */ basicAuth?: { + /** + * The user name sent to the proxy server. + */ username: string + /** + * The password sent to the proxy server. + */ password: string } /** @@ -116,14 +127,24 @@ const ERROR_REQUEST_CANCELLED = 'Request cancelled' * Fetch a resource from the network. It returns a `Promise` that resolves to the * `Response` to that `Request`, whether it is successful or not. * + * The request is performed by the Rust backend instead of the webview, so it is not subject to + * CORS, but the URL must be allowed by the plugin scope. + * * @example * ```typescript + * import { fetch } from '@tauri-apps/plugin-http'; * const response = await fetch("http://my.json.host/data.json"); * console.log(response.status); // e.g. 200 * console.log(response.statusText); // e.g. "OK" * const jsonData = await response.json(); * ``` * + * @param input The resource to fetch, as a URL, a string or a `Request` object. + * @param init The standard `fetch` request options, extended with the Rust client options from + * {@linkcode ClientOptions}: `maxRedirections`, `connectTimeout`, `proxy` and `danger`. The + * `signal` option can be used to abort the request. + * @returns A promise resolving to the `Response` of the request. + * * @since 2.0.0 */ export async function fetch( diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs index ef8de0c52..2b974e692 100644 --- a/plugins/http/src/error.rs +++ b/plugins/http/src/error.rs @@ -5,42 +5,68 @@ use serde::{Serialize, Serializer}; use url::Url; +/// Errors that can happen while using the HTTP plugin. +/// +/// The error is serialized to the frontend as its [`Display`](std::fmt::Display) string. #[derive(Debug, thiserror::Error)] pub enum Error { + /// JSON serialization or deserialization error. #[error(transparent)] Json(#[from] serde_json::Error), + /// File system error, raised for instance when the cookie store file cannot be created or + /// opened on startup. #[error(transparent)] Io(#[from] std::io::Error), + /// Error from the underlying [`reqwest`] client, raised while building the client, sending the + /// request or reading the response body. #[error(transparent)] Network(#[from] reqwest::Error), + /// Error from the [`http`] crate, raised while building the response of a `data:` URL. #[error(transparent)] Http(#[from] http::Error), + /// A header name given by the frontend is not a valid HTTP header name. #[error(transparent)] HttpInvalidHeaderName(#[from] http::header::InvalidHeaderName), + /// A header value is not a valid HTTP header value. #[error(transparent)] HttpInvalidHeaderValue(#[from] http::header::InvalidHeaderValue), /// URL not allowed by the scope. + /// + /// Raised for the URL requested by the frontend, and - when the + /// [`scope_redirects`](crate::Config::scope_redirects) configuration is enabled - for any + /// redirect target that is not allowed by the scope. #[error("url not allowed on the configured scope: {0}")] UrlNotAllowed(Url), + /// Failed to parse a URL. #[error(transparent)] UrlParseError(#[from] url::ParseError), /// HTTP method error. #[error(transparent)] HttpMethod(#[from] http::method::InvalidMethod), + /// The requested URL uses an unsupported scheme. Only `http`, `https` and `data` are handled. #[error("scheme {0} not supported")] SchemeNotSupport(String), + /// The request was aborted by the frontend before the response was received. #[error("Request canceled")] RequestCanceled, + /// Error from the file system plugin. #[error(transparent)] FsError(#[from] tauri_plugin_fs::Error), + /// The `data:` URL could not be processed. #[error("failed to process data url")] DataUrlError, + /// The body of the `data:` URL could not be decoded into bytes. #[error("failed to decode data url into bytes")] DataUrlDecodeError, + /// Error from the Tauri APIs, raised for instance while resolving a path or while reading the + /// webview resource table. #[error(transparent)] Tauri(#[from] tauri::Error), + /// A response header value is not valid UTF-8, so it cannot be forwarded to the frontend. #[error(transparent)] Utf8(#[from] std::string::FromUtf8Error), + /// The frontend requested dangerous client settings, but the `dangerous-settings` Cargo + /// feature is not enabled. #[error("dangerous settings used but are not enabled")] DangerousSettings, } @@ -54,4 +80,5 @@ impl Serialize for Error { } } +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index e33275476..6444ef99e 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -84,6 +84,16 @@ pub(crate) struct Http { cookies_jar: std::sync::Arc, } +/// Initializes the plugin. +/// +/// The plugin reads its [`Config`] from the `plugins > http` object of the `tauri.conf.json` file; +/// when that object is missing, [`Config::default`] is used. +/// +/// With the `cookies` Cargo feature (enabled by default), a cookie jar is loaded from a `.cookies` +/// file in the application cache directory on setup and written back to it when the application +/// exits. A jar that cannot be read is replaced by an empty one. +/// +/// Register it on the Tauri builder with `.plugin(tauri_plugin_http::init())`. pub fn init() -> TauriPlugin> { Builder::>::new("http") .setup(|app, api| { diff --git a/plugins/localhost/src/lib.rs b/plugins/localhost/src/lib.rs index 5d948b015..aba034b94 100644 --- a/plugins/localhost/src/lib.rs +++ b/plugins/localhost/src/lib.rs @@ -20,21 +20,28 @@ use tauri::{ }; use tiny_http::{Header, Response as HttpResponse, Server}; +/// An incoming HTTP request received by the localhost server, passed to the +/// [`Builder::on_request`] hook. pub struct Request { url: String, } impl Request { + /// The request URL (path and, if present, query string), exactly as sent by the client. pub fn url(&self) -> &str { &self.url } } +/// The HTTP response the localhost server is about to send back for a matched asset. Passed +/// mutably to the [`Builder::on_request`] hook so it can add or override headers before the +/// response is written to the client. pub struct Response { headers: HashMap, } impl Response { + /// Adds a header to the response, replacing any existing header with the same name. pub fn add_header, V: Into>(&mut self, header: H, value: V) { self.headers.insert(header.into(), value.into()); } @@ -42,6 +49,11 @@ impl Response { type OnRequest = Option>; +/// Builds the localhost plugin. +/// +/// **Note: This plugin brings considerable security risks and you should only use it if you know +/// what you are doing. Because the server has no authentication, any local process can connect to +/// it and read the assets it serves. If in doubt, use the default custom protocol implementation.** pub struct Builder { port: u16, host: Option, @@ -49,6 +61,23 @@ pub struct Builder { } impl Builder { + /// Creates a new [`Builder`] that will serve the app's assets on the given `port`, bound to + /// `localhost` unless [`Self::host`] is called. + /// + /// # Examples + /// + /// ```no_run + /// fn setup(builder: tauri::Builder) -> tauri::Builder { + /// builder.plugin( + /// tauri_plugin_localhost::Builder::new(9527) + /// .host("127.0.0.1") + /// .on_request(|request, response| { + /// println!("{}", request.url()); + /// }) + /// .build(), + /// ) + /// } + /// ``` pub fn new(port: u16) -> Self { Self { port, @@ -57,12 +86,15 @@ impl Builder { } } - // Change the host the plugin binds to. Defaults to `localhost`. + /// Sets the host the localhost server binds to. Defaults to `localhost`. pub fn host>(mut self, host: H) -> Self { self.host = Some(host.into()); self } + /// Sets a hook that is called for every request that resolves to a known frontend asset, + /// right before the response is sent. Use it to inspect the [`Request`] and add or override + /// headers on the [`Response`], for example to append custom CORS or caching headers. pub fn on_request( mut self, f: F, @@ -71,6 +103,18 @@ impl Builder { self } + /// Builds the plugin, ready to be registered with [`tauri::Builder::plugin`]. + /// + /// On setup it spawns a background thread that starts a `tiny_http` server bound to + /// `host:port`. For every incoming request whose path resolves to a known frontend asset, the + /// server responds with that asset's bytes, automatically setting the `Content-Type`, the + /// `Content-Security-Policy` (when the asset has one) and a `Cache-Control: no-cache` header, + /// then invoking the [`Self::on_request`] hook (if any) before writing the response. + /// + /// # Panics + /// + /// Panics on the background thread if the server fails to bind to `host:port`, or if it fails + /// to send a response for a request. pub fn build(mut self) -> TauriPlugin { let port = self.port; let host = self.host.unwrap_or("localhost".to_string()); diff --git a/plugins/log/guest-js/index.ts b/plugins/log/guest-js/index.ts index 93022a97b..87537e2e1 100644 --- a/plugins/log/guest-js/index.ts +++ b/plugins/log/guest-js/index.ts @@ -2,15 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Configure the Tauri application log system, and access the JavaScript-side log functions. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn, type Event } from '@tauri-apps/api/event' +/** + * Options to associate extra metadata with a log entry. + */ export interface LogOptions { + /** The name of the file that emitted the log entry. Included in the log record's target when set. */ file?: string + /** The line number in {@linkcode LogOptions.file} that emitted the log entry. */ line?: number + /** Additional structured key-value pairs to attach to the log entry. */ keyValues?: Record } +/** + * The verbosity level of a log entry, matching the levels of the Rust `log` crate. + */ export enum LogLevel { /** * The "trace" level. @@ -132,11 +147,8 @@ async function log( /** * Logs a message at the error level. * - * @param message - * - * # Examples - * - * ```js + * @example + * ```typescript * import { error } from '@tauri-apps/plugin-log'; * * const err_info = "No connection"; @@ -144,6 +156,10 @@ async function log( * * error(`Error: ${err_info} on port ${port}`); * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 */ export async function error( message: string, @@ -155,17 +171,18 @@ export async function error( /** * Logs a message at the warn level. * - * @param message - * - * # Examples - * - * ```js + * @example + * ```typescript * import { warn } from '@tauri-apps/plugin-log'; * * const warn_description = "Invalid Input"; * * warn(`Warning! {warn_description}!`); * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 */ export async function warn( message: string, @@ -177,17 +194,18 @@ export async function warn( /** * Logs a message at the info level. * - * @param message - * - * # Examples - * - * ```js + * @example + * ```typescript * import { info } from '@tauri-apps/plugin-log'; * * const conn_info = { port: 40, speed: 3.20 }; * * info(`Connected to port {conn_info.port} at {conn_info.speed} Mb/s`); * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 */ export async function info( message: string, @@ -199,17 +217,18 @@ export async function info( /** * Logs a message at the debug level. * - * @param message - * - * # Examples - * - * ```js + * @example + * ```typescript * import { debug } from '@tauri-apps/plugin-log'; * * const pos = { x: 3.234, y: -1.223 }; * * debug(`New position: x: {pos.x}, y: {pos.y}`); * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 */ export async function debug( message: string, @@ -221,17 +240,18 @@ export async function debug( /** * Logs a message at the trace level. * - * @param message - * - * # Examples - * - * ```js + * @example + * ```typescript * import { trace } from '@tauri-apps/plugin-log'; * * let pos = { x: 3.234, y: -1.223 }; * * trace(`Position is: x: {pos.x}, y: {pos.y}`); * ``` + * + * @param message the message to log. + * @param options additional metadata (file, line, key-values) to attach to the log entry. + * @since 2.0.0 */ export async function trace( message: string, @@ -249,9 +269,22 @@ type LoggerFn = (fn: RecordPayload) => void /** * Attaches a listener for the log, and calls the passed function for each log entry. - * @param fn * - * @returns a function to cancel the listener. + * @example + * ```typescript + * import { attachLogger } from '@tauri-apps/plugin-log'; + * + * const detach = await attachLogger(({ level, message }) => { + * console.log(`[${level}] ${message}`); + * }); + * + * // detach the listener later + * detach(); + * ``` + * + * @param fn a function to call for every log entry emitted by the Rust side. + * @returns a promise resolving to a function to cancel the listener. + * @since 2.0.0 */ export async function attachLogger(fn: LoggerFn): Promise { return await listen('log://log', (event: Event) => { @@ -272,7 +305,18 @@ export async function attachLogger(fn: LoggerFn): Promise { /** * Attaches a listener that writes log entries to the console as they come in. * - * @returns a function to cancel the listener. + * @example + * ```typescript + * import { attachConsole } from '@tauri-apps/plugin-log'; + * + * const detach = await attachConsole(); + * + * // detach the listener later + * detach(); + * ``` + * + * @returns a promise resolving to a function to cancel the listener. + * @since 2.0.0 */ export async function attachConsole(): Promise { return await attachLogger(({ level, message }: RecordPayload) => { diff --git a/plugins/log/src/lib.rs b/plugins/log/src/lib.rs index 9366f8584..18e872758 100644 --- a/plugins/log/src/lib.rs +++ b/plugins/log/src/lib.rs @@ -6,7 +6,7 @@ //! //! ## Cargo features //! -//! - **colored**: Enables [`Builder::with_colors`] `fern`'s `colored` feature for ANSI-colored outputs. +//! - **colored**: Enables `Builder::with_colors`, which uses `fern`'s `colored` feature for ANSI-colored outputs. //! - **tracing**: Emit both log and tracing for the JavaScript log commands. #![doc( @@ -39,6 +39,8 @@ pub use log; mod commands; +/// The log target prefix used for log records emitted through the JavaScript `trace`/`debug`/`info`/`warn`/`error` +/// bindings, optionally followed by `::{location}` when the caller's source location could be determined. pub const WEBVIEW_TARGET: &str = "webview"; #[cfg(target_os = "ios")] @@ -59,16 +61,23 @@ const DEFAULT_LOG_TARGETS: [Target; 2] = [ const LOG_DATE_FORMAT: &[time::format_description::FormatItem<'_>] = format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]"); +/// The error type returned by this plugin's fallible operations. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Forwarded from a call into the Tauri API, e.g. resolving the app log directory. #[error(transparent)] Tauri(#[from] tauri::Error), + /// Forwarded from a filesystem operation performed while creating, writing to or rotating a log file. #[error(transparent)] Io(#[from] std::io::Error), + /// Forwarded from formatting a timestamp with the configured [`TimezoneStrategy`]. #[error(transparent)] TimeFormat(#[from] time::error::Format), + /// Forwarded from parsing an invalid `time` format description. #[error(transparent)] InvalidFormatDescription(#[from] time::error::InvalidFormatDescription), + /// Returned by [`Builder::split`] when [`Builder::skip_logger`] was called on the builder, since no logger was + /// created for it to return. #[error("Internal logger disabled and cannot be acquired or attached")] LoggerNotInitialized, } @@ -125,6 +134,7 @@ impl From for LogLevel { } } +/// The strategy applied to a log file when it exceeds [`Builder::max_file_size`]. #[derive(Debug, Clone)] pub enum RotationStrategy { /// Will keep all the logs, renaming them to include the date. @@ -135,13 +145,17 @@ pub enum RotationStrategy { KeepSome(usize), } +/// The timezone used to compute the current time for log message timestamps and rotated log file names. #[derive(Debug, Clone)] pub enum TimezoneStrategy { + /// Use UTC time. UseUtc, + /// Use the local timezone. Falls back to UTC if the local offset cannot be determined. UseLocal, } impl TimezoneStrategy { + /// Returns the current time according to this strategy. pub fn get_now(&self) -> OffsetDateTime { match self { TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(), @@ -152,6 +166,7 @@ impl TimezoneStrategy { } } +/// The strategy used to open a log file when a [`TargetKind::Folder`] or [`TargetKind::LogDir`] target is set up. #[derive(Debug, Clone, PartialEq)] pub enum FileOpenStrategy { /// Open existing file from last session and append, if any. @@ -351,7 +366,9 @@ pub enum TargetKind { /// /// The plugin will ensure the directory exists before writing logs. Folder { + /// Path of the directory to write the log file to. path: PathBuf, + /// Name of the log file, without extension. Defaults to the app's package name when `None`. file_name: Option, }, /// Write logs to the OS specific logs directory. @@ -364,7 +381,10 @@ pub enum TargetKind { /// | macOS/iOS | `{homeDir}/Library/Logs/{bundleIdentifier}` | `/Users/Alice/Library/Logs/com.tauri.dev` | /// | Windows | `{FOLDERID_LocalAppData}/{bundleIdentifier}/logs` | `C:\Users\Alice\AppData\Local\com.tauri.dev\logs` | /// | Android | `{ConfigDir}/logs` | `/data/data/com.tauri.dev/files/logs` | - LogDir { file_name: Option }, + LogDir { + /// Name of the log file, without extension. Defaults to the app's package name when `None`. + file_name: Option, + }, /// Forward logs to the webview (via the `log://log` event). /// /// This requires the webview to subscribe to log events, via this plugins `attachConsole` function. @@ -385,6 +405,7 @@ pub struct Target { } impl Target { + /// Creates a new [`Target`] of the given [`TargetKind`], with no filters and the [`Builder`]'s formatter. #[inline] pub const fn new(kind: TargetKind) -> Self { Self { @@ -394,6 +415,8 @@ impl Target { } } + /// Adds a filter that can reject log records from being sent to this target. Multiple filters may be added by + /// calling this method more than once. #[inline] pub fn filter(mut self, filter: F) -> Self where @@ -403,6 +426,8 @@ impl Target { self } + /// Sets a formatter for log records sent to this target, overriding the [`Builder`]'s formatter for this + /// target only. #[inline] pub fn format(mut self, formatter: F) -> Self where @@ -413,6 +438,8 @@ impl Target { } } +/// Builds the log plugin, configuring targets, rotation, formatting and level filters before attaching the +/// resulting logger as the global logger for the [`log`] crate. pub struct Builder { dispatch: fern::Dispatch, rotation_strategy: RotationStrategy, @@ -454,6 +481,10 @@ impl Default for Builder { } impl Builder { + /// Creates a new [`Builder`] with the default configuration: targets [`TargetKind::Stdout`] and + /// [`TargetKind::LogDir`] (using the app's package name as the file name), [`RotationStrategy::KeepOne`], + /// [`TimezoneStrategy::UseUtc`], [`FileOpenStrategy::Append`], a maximum log file size of `40_000` bytes + /// (see [`Self::max_file_size`]) and the default `fern` formatter. pub fn new() -> Self { Default::default() } @@ -604,14 +635,13 @@ impl Builder { /// Example usage: /// /// ``` - /// # fn main() { /// tauri_plugin_log::Builder::new() /// .level(log::LevelFilter::Info) /// .filter(|metadata| { /// // Reject messages with the `Error` log level. - /// metadata.level() != log::LevelFilter::Error - /// }) - /// # } + /// metadata.level() != log::Level::Error + /// }); + /// ``` pub fn filter(mut self, filter: F) -> Self where F: Fn(&log::Metadata) -> bool + Send + Sync + 'static, @@ -661,10 +691,16 @@ impl Builder { /// Both scenarios require calling this method. /// /// ```rust + /// # struct SimpleLogger; + /// # impl log::Log for SimpleLogger { + /// # fn enabled(&self, _metadata: &log::Metadata) -> bool { true } + /// # fn log(&self, _record: &log::Record) {} + /// # fn flush(&self) {} + /// # } /// static LOGGER: SimpleLogger = SimpleLogger; /// - /// log::set_logger(&SimpleLogger)?; - /// log::set_max_level(LevelFilter::Info); + /// log::set_logger(&LOGGER).expect("a logger was already set"); + /// log::set_max_level(log::LevelFilter::Info); /// tauri_plugin_log::Builder::new() /// .skip_logger(); /// ``` @@ -824,6 +860,15 @@ impl Builder { plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log]) } + /// Builds the [`TauriPlugin`] and returns it together with the configured [`log::LevelFilter`] and [`log::Log`] + /// implementation, instead of registering them as the global logger. + /// + /// Use this instead of [`Self::build`] when this logger needs to be combined with another one, e.g. when + /// forwarding logs to `tracing` via `tracing-log`, since only a single global logger can be installed. + /// + /// # Errors + /// + /// Returns [`Error::LoggerNotInitialized`] if [`Self::skip_logger`] was called on this builder. #[allow(clippy::type_complexity)] pub fn split( self, @@ -846,6 +891,8 @@ impl Builder { Ok((plugin.build(), max_level, log)) } + /// Builds the [`TauriPlugin`], attaching the configured logger as the global logger for the [`log`] crate on + /// setup, unless [`Self::skip_logger`] was called on this builder. pub fn build(self) -> TauriPlugin { Self::plugin_builder() .setup(move |app_handle, _api| { diff --git a/plugins/nfc/guest-js/index.ts b/plugins/nfc/guest-js/index.ts index 9c1fa10b2..35489b9cb 100644 --- a/plugins/nfc/guest-js/index.ts +++ b/plugins/nfc/guest-js/index.ts @@ -2,39 +2,95 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Read and write NFC tags on Android and iOS. + * + * This plugin is mobile only, the APIs reject on desktop platforms. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' +/** + * Record Type Definition (RTD) of an NDEF well known text record, the `"T"` (`0x54`) byte. + * + * @since 2.0.0 + */ export const RTD_TEXT = [0x54] // "T" +/** + * Record Type Definition (RTD) of an NDEF well known URI record, the `"U"` (`0x55`) byte. + * + * @since 2.0.0 + */ export const RTD_URI = [0x55] // "U" +/** + * Filters the tags to scan by the URI of their payload. + * + * Every property is optional and only the ones that are set take part in the filter. + * **Android only**, the iOS implementation ignores this filter. + */ export interface UriFilter { + /** Only match URIs with this scheme, e.g. `https`. */ scheme?: string + /** Only match URIs with this authority (host), e.g. `tauri.app`. */ host?: string + /** Only match URIs whose path starts with this prefix, e.g. `/docs`. */ pathPrefix?: string } +/** + * The NFC technologies a tag can support, mirroring the `android.nfc.tech` classes. + * + * **Android only**, used by the `techLists` filter of an `ndef` {@link ScanKind}. + */ export enum TechKind { + /** ISO-DEP (ISO 14443-4) properties and I/O operations. */ IsoDep, + /** MIFARE Classic properties and I/O operations. */ MifareClassic, + /** MIFARE Ultralight and MIFARE Ultralight C properties and I/O operations. */ MifareUltralight, + /** NDEF data and operations on tags that are already formatted as NDEF. */ Ndef, + /** Formatting operations on tags that can be formatted as NDEF but are not yet. */ NdefFormatable, + /** NFC-A (ISO 14443-3A) properties and I/O operations. */ NfcA, + /** NFC-B (ISO 14443-3B) properties and I/O operations. */ NfcB, + /** NFC Barcode (Kovio NFC Barcode) properties and I/O operations. */ NfcBarcode, + /** NFC-F (JIS 6319-4) properties and I/O operations. */ NfcF, + /** NFC-V (ISO 15693) properties and I/O operations. */ NfcV } +/** + * The kind of scan to perform, which defines which tags are matched. + * + * Use `tag` to match any discovered tag and `ndef` to only match tags carrying an NDEF message. + */ export type ScanKind = | { + /** Matches any tag that is discovered, whether it carries an NDEF message or not. */ type: 'tag' + /** Only match tags whose payload URI matches this filter. **Android only**. */ uri?: UriFilter + /** Only match tags whose payload has this MIME type, e.g. `text/plain`. **Android only**. */ mimeType?: string } | { + /** Only matches tags that carry an NDEF message. */ type: 'ndef' + /** Only match tags whose NDEF payload URI matches this filter. **Android only**. */ uri?: UriFilter + /** + * Only match tags whose NDEF payload has this MIME type, e.g. `text/plain`. + * **Android only**. + */ mimeType?: string /** * Each of the tech-lists is considered independently and the activity is considered a match if @@ -59,7 +115,13 @@ export type ScanKind = techLists?: TechKind[][] } +/** Options for the {@link scan} function. */ export interface ScanOptions { + /** + * Whether the connection to the scanned tag must be kept open after the scan resolves, + * so that a following {@link write} call writes to that tag instead of scanning a new one. + * Defaults to `false`. + */ keepSessionAlive?: boolean /** Message displayed in the UI. iOS only. */ message?: string @@ -67,7 +129,12 @@ export interface ScanOptions { successMessage?: string } +/** Options for the {@link write} function. */ export interface WriteOptions { + /** + * The kind of scan to perform to find the tag to write to. + * Required on Android unless a {@link scan} session is kept alive. + */ kind?: ScanKind /** Message displayed in the UI when reading the tag. iOS only. */ message?: string @@ -77,36 +144,101 @@ export interface WriteOptions { successMessage?: string } +/** + * The Type Name Format (TNF) of an NDEF record, + * which defines how the record type is interpreted. + */ export enum NFCTypeNameFormat { + /** The record is empty: type, identifier and payload must be empty. */ Empty = 0, + /** + * The record type is an NFC Forum well known type, defined by a Record Type Definition (RTD) + * such as {@link RTD_TEXT} or {@link RTD_URI}. + */ NfcWellKnown = 1, + /** The record type is a MIME media type as defined in RFC 2046, e.g. `text/plain`. */ Media = 2, + /** The record type is an absolute URI as defined in RFC 3986. */ AbsoluteURI = 3, + /** The record type is an NFC Forum external type, i.e. a type namespaced by its issuer. */ NfcExternal = 4, + /** + * The record type is unknown: the type must be empty and the payload interpretation + * is left to the application. + */ Unknown = 5, + /** + * The record is a middle or last chunk of a chunked record and inherits the type of the + * first chunk, so its own type must be empty. + */ Unchanged = 6 } +/** An NDEF record read from a scanned tag. */ export interface TagRecord { + /** + * The Type Name Format (TNF) of the record, + * which defines how {@link TagRecord.kind} is interpreted. + */ tnf: NFCTypeNameFormat + /** The record type bytes. */ kind: number[] + /** The record identifier bytes. Can be empty. */ id: number[] + /** The record payload bytes. */ payload: number[] } +/** An NFC tag that has been scanned. */ export interface Tag { + /** The tag identifier bytes, as reported by the operating system. */ id: number[] + /** The technologies the tag supports, e.g. `["android.nfc.tech.Ndef"]` on Android. */ kind: string[] + /** The NDEF records stored on the tag. Empty when the tag holds no NDEF message. */ records: TagRecord[] } +/** + * An NDEF record to be written to a tag. + * + * Use {@link record}, {@link textRecord} or {@link uriRecord} to create one. + */ export interface NFCRecord { + /** The Type Name Format (TNF) of the record. */ format: NFCTypeNameFormat + /** + * The record type, interpreted according to {@link NFCRecord.format}. + * For well known records this is a Record Type Definition (RTD) value + * such as {@link RTD_TEXT} or {@link RTD_URI}. + */ kind: number[] + /** The record identifier. Can be empty. */ id: number[] + /** The record payload bytes. */ payload: number[] } +/** + * Creates an NDEF record with the given type name format, type, identifier and payload. + * + * Strings are encoded as UTF-8 byte arrays. + * + * @example + * ```typescript + * import { record, NFCTypeNameFormat, write } from '@tauri-apps/plugin-nfc'; + * const mimeRecord = record(NFCTypeNameFormat.Media, 'text/plain', '', 'hello world'); + * await write([mimeRecord], { kind: { type: 'ndef' } }); + * ``` + * + * @param format The Type Name Format (TNF) of the record. + * @param kind The record type, interpreted according to `format`. + * @param id The record identifier. Use an empty string or array when the record has none. + * @param payload The record payload. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ export function record( format: NFCTypeNameFormat, kind: string | number[], @@ -127,6 +259,25 @@ export function record( } } +/** + * Creates an NDEF well known text record ({@link RTD_TEXT}). + * + * The payload is the UTF-8 encoded text prefixed by the language code + * and by a status byte holding the length of that language code. + * + * @example + * ```typescript + * import { textRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([textRecord('hello world')], { kind: { type: 'ndef' } }); + * ``` + * + * @param text The text to store in the record. + * @param id The record identifier. Defaults to an empty identifier. + * @param language The IANA language code of the text. Defaults to `en`. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ export function textRecord( text: string, id?: string | number[], @@ -200,6 +351,24 @@ function encodeURI(uri: string): number[] { return encoded } +/** + * Creates an NDEF well known URI record ({@link RTD_URI}). + * + * The URI is encoded with the NDEF URI abbreviation scheme: a known prefix such as + * `https://` is replaced by the identifier code byte that starts the payload. + * + * @example + * ```typescript + * import { uriRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([uriRecord('https://tauri.app')], { kind: { type: 'ndef' } }); + * ``` + * + * @param uri The URI to store in the record. + * @param id The record identifier. Defaults to an empty identifier. + * @returns The NDEF record, ready to be written with {@link write}. + * + * @since 2.0.0 + */ export function uriRecord(uri: string, id?: string | number[]): NFCRecord { return record( NFCTypeNameFormat.NfcWellKnown, @@ -215,18 +384,22 @@ function mapScanKind(kind: ScanKind): Record { } /** - * Scans an NFC tag. - * - * ```javascript - * import { scan } from "@tauri-apps/plugin-nfc"; - * await scan({ type: "tag" }); - * ``` + * Scans an NFC tag, resolving when a tag matching the given filters is read. * * See for more information. * - * @param kind - * @param options - * @returns + * @example + * ```typescript + * import { scan } from '@tauri-apps/plugin-nfc'; + * const tag = await scan({ type: 'tag' }); + * ``` + * + * @param kind The kind of scan to perform, which defines how tags are matched. + * @param options Additional scan options such as the iOS UI messages and whether the + * session must be kept alive for a following {@link write} call. + * @returns A promise resolving to the tag that has been scanned. + * + * @since 2.0.0 */ export async function scan( kind: ScanKind, @@ -241,17 +414,20 @@ export async function scan( /** * Write to an NFC tag. * - * ```javascript - * import { uriRecord, write } from "@tauri-apps/plugin-nfc"; - * await write([uriRecord("https://tauri.app")], { kind: { type: "ndef" } }); - * ``` - * * If you did not previously call {@link scan} with {@link ScanOptions.keepSessionAlive} set to true, * it will first scan the tag then write to it. * - * @param records - * @param options - * @returns + * @example + * ```typescript + * import { uriRecord, write } from '@tauri-apps/plugin-nfc'; + * await write([uriRecord('https://tauri.app')], { kind: { type: 'ndef' } }); + * ``` + * + * @param records The NDEF records to write to the tag. + * @param options Additional write options such as the kind of scan used to find the tag + * and the iOS UI messages. + * + * @since 2.0.0 */ export async function write( records: NFCRecord[], @@ -268,6 +444,23 @@ export async function write( }) } +/** + * Checks whether NFC is supported by the device and currently usable by the app. + * + * Resolves to `false` on Android when the device has no NFC adapter or NFC is disabled in the + * device settings, and on iOS when the `NFCReaderUsageDescription` entry is missing from the + * `Info.plist` file or NFC tag reading is unavailable. + * + * @example + * ```typescript + * import { isAvailable } from '@tauri-apps/plugin-nfc'; + * const canScan = await isAvailable(); + * ``` + * + * @returns A promise resolving to whether NFC is available on the device. + * + * @since 2.0.0 + */ export async function isAvailable(): Promise { const { available }: { available: boolean } = await invoke( 'plugin:nfc|is_available' diff --git a/plugins/nfc/src/error.rs b/plugins/nfc/src/error.rs index 339e763b1..75d21ea92 100644 --- a/plugins/nfc/src/error.rs +++ b/plugins/nfc/src/error.rs @@ -4,12 +4,21 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for a [`std::result::Result`] with the error type [`Error`]. pub type Result = std::result::Result; +/// Errors returned by the NFC plugin. +/// +/// Serializes to the error message string, so it can be returned directly from a command. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error happened. #[error(transparent)] Io(#[from] std::io::Error), + /// The call to the Android or iOS plugin implementation failed, + /// either because the arguments could not be serialized, the response could not be + /// deserialized or the native side rejected the call (e.g. NFC is unavailable + /// or the tag could not be read or written). #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), diff --git a/plugins/nfc/src/lib.rs b/plugins/nfc/src/lib.rs index 33a47a96a..0396b7733 100644 --- a/plugins/nfc/src/lib.rs +++ b/plugins/nfc/src/lib.rs @@ -2,6 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Read and write NFC tags on Android and iOS. +//! +//! This plugin is mobile only: the whole crate is gated behind `#[cfg(mobile)]`, +//! so it expands to nothing when compiling for Linux, macOS or Windows. +//! +//! Register the plugin with `init` and use the `NfcExt` trait to reach the +//! `Nfc` instance from any `tauri::Manager` implementation (the app handle, a window, ...). + #![cfg(mobile)] use serde::{Deserialize, Serialize}; @@ -37,6 +45,12 @@ struct WriteRequest { } impl Nfc { + /// Checks whether NFC is supported by the device and currently usable by the app. + /// + /// On Android this is `false` when the device has no NFC adapter or when NFC is + /// disabled in the device settings. + /// On iOS this is `false` when the `NFCReaderUsageDescription` entry is missing from the + /// `Info.plist` file or when NFC tag reading is not available on the device. pub fn is_available(&self) -> crate::Result { self.0 .run_mobile_plugin::("isAvailable", ()) @@ -44,6 +58,16 @@ impl Nfc { .map_err(Into::into) } + /// Scans an NFC tag, blocking until a tag matching the given [`ScanRequest::kind`] filters + /// is read or the scan fails. + /// + /// Set [`ScanRequest::keep_session_alive`] to `true` to keep the connection to the tag open + /// after it has been scanned, so that a following [`Self::write`] call writes to that tag. + /// + /// # Errors + /// + /// Returns an error when NFC is not available (see [`Self::is_available`]) + /// or when the tag could not be read. pub fn scan(&self, payload: ScanRequest) -> crate::Result { self.0 .run_mobile_plugin("scan", payload) @@ -51,6 +75,19 @@ impl Nfc { .map_err(Into::into) } + /// Writes the given NDEF records to an NFC tag, blocking until the write completes or fails. + /// + /// Because this API does not take a scan kind, on Android it can only write to the tag of an + /// ongoing session, so it must be preceded by a [`Self::scan`] call with + /// [`ScanRequest::keep_session_alive`] set to `true`. + /// On iOS an NDEF reader session is started when there is no ongoing session, and the + /// records are written to the first tag that is scanned. + /// + /// # Errors + /// + /// Returns an error when NFC is not available (see [`Self::is_available`]), when there is no + /// connected tag on Android, when the tag is read-only, when it cannot hold the message or + /// when it does not support the NDEF format. pub fn write(&self, records: Vec) -> crate::Result<()> { self.0 .run_mobile_plugin("write", WriteRequest { records }) @@ -60,6 +97,7 @@ impl Nfc { /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the NFC APIs. pub trait NfcExt { + /// Returns the [`Nfc`] instance managed by the plugin. fn nfc(&self) -> &Nfc; } diff --git a/plugins/nfc/src/models.rs b/plugins/nfc/src/models.rs index eb05cf7ac..ccb4c55e1 100644 --- a/plugins/nfc/src/models.rs +++ b/plugins/nfc/src/models.rs @@ -5,72 +5,132 @@ use serde::{Deserialize, Serialize, Serializer}; use std::fmt::Display; +/// Arguments of the [`Nfc::scan`](crate::Nfc::scan) API. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ScanRequest { + /// The kind of scan to perform, which defines how tags are matched. pub kind: ScanKind, + /// Whether the connection to the scanned tag must be kept open after the scan resolves. + /// + /// When `true`, a following [`Nfc::write`](crate::Nfc::write) call writes to the tag that was + /// scanned instead of starting a new session. pub keep_session_alive: bool, } +/// An NDEF record to be written to a tag. +/// +/// Use [`NFCTypeNameFormat`] to describe how [`Self::kind`] must be interpreted. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NfcRecord { + /// The Type Name Format (TNF) of the record. pub format: NFCTypeNameFormat, + /// The record type, interpreted according to [`Self::format`]. + /// + /// For [`NFCTypeNameFormat::NfcWellKnown`] records this is a Record Type Definition (RTD) + /// value such as `[0x54]` (`RTD_TEXT`) or `[0x55]` (`RTD_URI`). pub kind: Vec, + /// The record identifier. Can be empty. pub id: Vec, + /// The record payload bytes. pub payload: Vec, } +/// The Type Name Format (TNF) of an NDEF record, which defines how the record type is interpreted. +/// +/// Serialized as its numeric value. #[derive(serde_repr::Deserialize_repr, serde_repr::Serialize_repr)] #[repr(u8)] pub enum NFCTypeNameFormat { + /// The record is empty: type, identifier and payload must be empty. Empty = 0, + /// The record type is an NFC Forum well known type, defined by a Record Type Definition (RTD) + /// such as `RTD_TEXT` (`[0x54]`) or `RTD_URI` (`[0x55]`). NfcWellKnown = 1, + /// The record type is a MIME media type as defined in RFC 2046, e.g. `text/plain`. Media = 2, + /// The record type is an absolute URI as defined in RFC 3986. AbsoluteURI = 3, + /// The record type is an NFC Forum external type, i.e. a type namespaced by its issuer. NfcExternal = 4, + /// The record type is unknown: the type must be empty and the payload interpretation is + /// left to the application. Unknown = 5, + /// The record is a middle or last chunk of a chunked record and inherits the type of the + /// first chunk, so its own type must be empty. Unchanged = 6, } +/// An NDEF record read from a scanned tag. #[derive(Deserialize)] pub struct NfcTagRecord { + /// The Type Name Format (TNF) of the record, which defines how [`Self::kind`] is interpreted. pub tnf: NFCTypeNameFormat, + /// The record type bytes. pub kind: Vec, + /// The record identifier bytes. Can be empty. pub id: Vec, + /// The record payload bytes. pub payload: Vec, } +/// An NFC tag that has been scanned. #[derive(Deserialize)] pub struct NfcTag { + /// The tag identifier, as reported by the operating system. pub id: String, + /// The technology the tag supports. pub kind: String, + /// The NDEF records stored on the tag. Empty when the tag holds no NDEF message. pub records: Vec, } +/// Response of the [`Nfc::scan`](crate::Nfc::scan) API. #[derive(Deserialize)] pub struct ScanResponse { + /// The tag that has been scanned. pub tag: NfcTag, } +/// Filters the tags to scan by the URI of their NDEF payload. +/// +/// Every field is optional and only the ones that are set take part in the filter. +/// **Android only**: the iOS implementation ignores this filter. #[derive(Debug, Default, Serialize)] pub struct UriFilter { + /// Only match URIs with this scheme, e.g. `https`. scheme: Option, + /// Only match URIs with this authority (host), e.g. `tauri.app`. host: Option, + /// Only match URIs whose path starts with this prefix, e.g. `/docs`. path_prefix: Option, } +/// The NFC technologies a tag can support, mirroring the `android.nfc.tech` classes. +/// +/// **Android only**. Serialized as the technology name, e.g. `"IsoDep"`. #[derive(Debug)] pub enum TechKind { + /// ISO-DEP (ISO 14443-4) properties and I/O operations. IsoDep, + /// MIFARE Classic properties and I/O operations. MifareClassic, + /// MIFARE Ultralight and MIFARE Ultralight C properties and I/O operations. MifareUltralight, + /// NDEF data and operations on tags that are already formatted as NDEF. Ndef, + /// Formatting operations on tags that can be formatted as NDEF but are not yet. NdefFormatable, + /// NFC-A (ISO 14443-3A) properties and I/O operations. NfcA, + /// NFC-B (ISO 14443-3B) properties and I/O operations. NfcB, + /// NFC Barcode (Kovio NFC Barcode) properties and I/O operations. NfcBarcode, + /// NFC-F (JIS 6319-4) properties and I/O operations. NfcF, + /// NFC-V (ISO 15693) properties and I/O operations. NfcV, } @@ -104,16 +164,32 @@ impl Serialize for TechKind { } } +/// The kind of scan to perform, which defines which tags are matched. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub enum ScanKind { + /// Only match tags that carry an NDEF message. Ndef { + /// Only match tags whose NDEF payload has this MIME type, e.g. `text/plain`. + /// **Android only**. mime_type: Option, + /// Only match tags whose NDEF payload URI matches this filter. **Android only**. uri: Option, + /// Only match tags supporting the listed technologies. + /// + /// Each tech list is considered independently and the tag matches when any single tech + /// list matches it, which provides AND (inside a list) and OR (between lists) semantics. + /// + /// **Android only**. See + /// + /// for more information. tech_list: Option>>, }, + /// Match any tag that is discovered, whether it carries an NDEF message or not. Tag { + /// Only match tags whose payload has this MIME type, e.g. `text/plain`. **Android only**. mime_type: Option, + /// Only match tags whose payload URI matches this filter. **Android only**. uri: Option, }, } diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 7045ac66e..ee9c24c91 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -35,7 +35,7 @@ interface Options { */ channelId?: string /** - * Notification title. + * The title displayed on the notification. */ title: string /** @@ -139,11 +139,28 @@ interface Options { number?: number } +/** + * The set of date fields a scheduled notification must match to be delivered. + * + * Fields that are not set match any value, so the notification fires on every date + * whose remaining components match. + */ interface ScheduleInterval { + /** + * The year the notification fires on. + */ year?: number + /** + * The month of the year the notification fires on. + */ month?: number + /** + * The day of the month the notification fires on. + */ day?: number /** + * The day of the week the notification fires on. + * * 1 - Sunday * 2 - Monday * 3 - Tuesday @@ -153,26 +170,77 @@ interface ScheduleInterval { * 7 - Saturday */ weekday?: number + /** + * The hour of the day the notification fires on, in the 24-hour clock. + */ hour?: number + /** + * The minute of the hour the notification fires on. + */ minute?: number + /** + * The second of the minute the notification fires on. + */ second?: number } +/** + * The unit of the repeating interval used by {@link Schedule.every}. + */ enum ScheduleEvery { + /** + * The notification repeats every year. + * + * On Android a year is approximated as 52 weeks. + */ Year = 'year', + /** + * The notification repeats every month. + * + * On Android a month is approximated as 30 days. + */ Month = 'month', + /** + * The notification repeats every two weeks. + */ TwoWeeks = 'twoWeeks', + /** + * The notification repeats every week. + */ Week = 'week', + /** + * The notification repeats every day. + */ Day = 'day', + /** + * The notification repeats every hour. + */ Hour = 'hour', + /** + * The notification repeats every minute. + */ Minute = 'minute', /** - * Not supported on iOS. + * The notification repeats every second. + * + * Not supported on iOS, where repeating triggers must be at least a minute apart. */ Second = 'second' } +/** + * Defines when a scheduled notification is delivered. + * + * Build one with the static {@link Schedule.at}, {@link Schedule.interval} and + * {@link Schedule.every} helpers, then pass it to the `schedule` option of a notification. + * Scheduling is only supported on mobile; desktop notifications are always shown immediately. + * + * @since 2.0.0 + */ class Schedule { + /** + * Set when the notification fires at a fixed date and time. + */ at: | { date: Date @@ -181,6 +249,9 @@ class Schedule { } | undefined + /** + * Set when the notification fires whenever the current date matches the given fields. + */ interval: | { interval: ScheduleInterval @@ -188,6 +259,9 @@ class Schedule { } | undefined + /** + * Set when the notification repeats on a fixed interval. + */ every: | { interval: ScheduleEvery @@ -196,6 +270,22 @@ class Schedule { } | undefined + /** + * Creates a schedule that fires the notification at the given date and time. + * + * @example + * ```typescript + * import { Schedule, sendNotification } from '@tauri-apps/plugin-notification'; + * const schedule = Schedule.at(new Date(Date.now() + 60 * 1000)); + * sendNotification({ title: 'Tauri', body: 'One minute later', schedule }); + * ``` + * + * @param date The date and time the notification fires at. It must be in the future. + * @param repeating Whether the notification keeps repeating, using the duration between the moment it is scheduled and `date` as the interval. The interval must be at least one minute on iOS. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ static at(date: Date, repeating = false, allowWhileIdle = false): Schedule { return { at: { date, repeating, allowWhileIdle }, @@ -204,6 +294,23 @@ class Schedule { } } + /** + * Creates a schedule that fires the notification whenever the current date matches + * every field set on the given interval. + * + * @example + * ```typescript + * import { Schedule, sendNotification } from '@tauri-apps/plugin-notification'; + * // fires every day at 9:00 + * const schedule = Schedule.interval({ hour: 9, minute: 0 }); + * sendNotification({ title: 'Tauri', body: 'Good morning', schedule }); + * ``` + * + * @param interval The date fields the current date must match for the notification to fire. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ static interval( interval: ScheduleInterval, allowWhileIdle = false @@ -215,6 +322,22 @@ class Schedule { } } + /** + * Creates a schedule that repeatedly fires the notification, once every `count` interval units. + * + * @example + * ```typescript + * import { Schedule, ScheduleEvery, sendNotification } from '@tauri-apps/plugin-notification'; + * const schedule = Schedule.every(ScheduleEvery.Hour, 2); + * sendNotification({ title: 'Tauri', body: 'Every two hours', schedule }); + * ``` + * + * @param kind The unit of the repeating interval. + * @param count How many interval units elapse between each notification. + * @param allowWhileIdle Whether the notification is allowed to fire while the device is in low-power idle (Doze) mode. Android only. + * + * @returns A schedule that can be assigned to the `schedule` option of a notification. + */ static every( kind: ScheduleEvery, count: number, @@ -238,17 +361,53 @@ interface Attachment { url: string } +/** + * A button the user can tap on a notification, belonging to an {@link ActionType}. + * + * Only used on mobile. On Android only the identifier, the title and the input flag are used. + */ interface Action { + /** + * The identifier of this action, reported back when the user triggers it. + */ id: string + /** + * The text displayed on the action button. + */ title: string + /** + * Whether the device must be unlocked for the action to run. iOS only. + */ requiresAuthentication?: boolean + /** + * Whether the app is brought to the foreground when the action is triggered. iOS only. + */ foreground?: boolean + /** + * Whether the action is displayed as destructive, usually in red. iOS only. + */ destructive?: boolean + /** + * Whether triggering the action lets the user type a text response. + */ input?: boolean + /** + * The text displayed on the button that submits the text input. iOS only. + */ inputButtonTitle?: string + /** + * The placeholder displayed on the empty text input field. iOS only. + */ inputPlaceholder?: string } +/** + * A group of {@link Action}s a notification can display, referenced by the + * `actionTypeId` option of a notification. + * + * Register it with {@link registerActionTypes} before sending a notification that uses it. + * Only used on mobile. + */ interface ActionType { /** * The identifier of this action type @@ -258,58 +417,206 @@ interface ActionType { * The list of associated actions */ actions: Action[] + /** + * The placeholder shown instead of the notification body when previews are hidden. iOS only. + */ hiddenPreviewsBodyPlaceholder?: string + /** + * Whether the app is notified when the user dismisses the notification. iOS only. + */ customDismissAction?: boolean + /** + * Whether the notification can be displayed in a CarPlay environment. iOS only. + */ allowInCarPlay?: boolean + /** + * Whether the notification title is shown even when previews are hidden. iOS only. + */ hiddenPreviewsShowTitle?: boolean + /** + * Whether the notification subtitle is shown even when previews are hidden. iOS only. + */ hiddenPreviewsShowSubtitle?: boolean } +/** + * A notification that was scheduled and has not been delivered yet. + * + * Returned by {@link pending}, which is only supported on mobile. + */ interface PendingNotification { + /** + * The identifier of the notification. + */ id: number + /** + * The title of the notification, if it was set. + */ title?: string + /** + * The body of the notification, if it was set. + */ body?: string + /** + * The schedule that determines when the notification is delivered. + */ schedule: Schedule } +/** + * A notification that was delivered and is still visible in the notification center. + * + * Returned by {@link active}, which is only supported on mobile. Which fields are + * populated depends on the platform, since Android and iOS expose different + * information about delivered notifications. + */ interface ActiveNotification { + /** + * The identifier of the notification. + */ id: number + /** + * The tag the notification was posted with. Android only. + */ tag?: string + /** + * The title of the notification, if it was set. + */ title?: string + /** + * The body of the notification, if it was set. + */ body?: string + /** + * The identifier of the group the notification belongs to. Android only. + */ group?: string + /** + * Whether the notification is the summary of its group. Android only. + */ groupSummary: boolean + /** + * The platform extras attached to the notification, as string values. Android only. + */ data: Record + /** + * The extra payload that was stored in the notification. + */ extra: Record + /** + * The attachments of the notification. iOS only. + */ attachments: Attachment[] + /** + * The identifier of the action type the notification was registered with. iOS only. + */ actionTypeId?: string + /** + * The schedule the notification was delivered with, if it was scheduled. + */ schedule?: Schedule + /** + * The sound resource name of the notification. iOS only. + */ sound?: string } +/** + * How much the notifications of a {@link Channel} interrupt the user. + * + * It maps to the Android `NotificationManager.IMPORTANCE_*` constants and is only used on Android. + */ enum Importance { + /** + * The notifications are not shown. + */ None = 0, + /** + * The notifications are only shown in the shade, below the fold, without a status bar icon. + */ Min, + /** + * The notifications are shown without a sound. + */ Low, + /** + * The notifications are shown and make a sound. + * + * This is the value used when the channel does not define an importance. + */ Default, + /** + * The notifications are shown, make a sound and pop up as a heads-up notification. + */ High } +/** + * How much of a notification is shown on the lock screen. + * + * It maps to the Android `Notification.VISIBILITY_*` constants and is only used on Android. + */ enum Visibility { + /** + * The notification is not shown on the lock screen at all. + */ Secret = -1, + /** + * The notification is shown on the lock screen with its sensitive content hidden. + * + * This is the value used when the channel does not define a visibility. + */ Private, + /** + * The notification is shown in full on the lock screen. + */ Public } +/** + * A notification channel, the category users configure notification behavior on. + * + * Notifications reference a channel through their `channelId` option and are not delivered + * when the channel does not exist. Channels are only supported on Android. + */ interface Channel { + /** + * The identifier of this channel. + */ id: string + /** + * The user visible name of this channel. + */ name: string + /** + * The user visible description of this channel. + */ description?: string + /** + * The name of the sound resource played by the notifications of this channel. + * + * The resource must be placed in the app's `res/raw` folder. + */ sound?: string + /** + * Whether the notifications of this channel blink the device light. + */ lights?: boolean + /** + * The color of the device light, as a color string such as `#ff0000`. + */ lightColor?: string + /** + * Whether the notifications of this channel vibrate the device. + */ vibration?: boolean + /** + * How much the notifications of this channel interrupt the user. + */ importance?: Importance + /** + * How much of the notifications of this channel is shown on the lock screen. + */ visibility?: Visibility } @@ -321,6 +628,8 @@ interface Channel { * const permissionGranted = await isPermissionGranted(); * ``` * + * @returns A promise resolving to whether the permission to send notifications is granted. + * * @since 2.0.0 */ async function isPermissionGranted(): Promise { @@ -366,6 +675,8 @@ async function requestPermission(): Promise { * } * ``` * + * @param options The notification content, or the notification title when a string is given. + * * @since 2.0.0 */ function sendNotification(options: Options | string): void { @@ -391,6 +702,8 @@ function sendNotification(options: Options | string): void { * }]) * ``` * + * @param types The action types to register. + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -425,6 +738,8 @@ async function pending(): Promise { * await cancel([-34234, 23432, 4311]); * ``` * + * @param notifications The identifiers of the pending notifications to cancel. + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -472,10 +787,12 @@ async function active(): Promise { * * @example * ```typescript - * import { cancel } from '@tauri-apps/plugin-notification'; - * await cancel([-34234, 23432, 4311]) + * import { removeActive } from '@tauri-apps/plugin-notification'; + * await removeActive([{ id: -34234 }, { id: 23432 }, { id: 4311 }]) * ``` * + * @param notifications The active notifications to remove, identified by their id and, on Android, their optional tag. + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -519,6 +836,8 @@ async function removeAllActive(): Promise { * }); * ``` * + * @param channel The channel to create. + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -533,9 +852,11 @@ async function createChannel(channel: Channel): Promise { * @example * ```typescript * import { removeChannel } from '@tauri-apps/plugin-notification'; - * await removeChannel(); + * await removeChannel('new-messages'); * ``` * + * @param id The identifier of the channel to remove. + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -561,12 +882,51 @@ async function channels(): Promise { return await invoke('plugin:notification|listChannels') } +/** + * Listens to notifications that are delivered while the app is running. + * + * Only emitted on mobile. + * + * @example + * ```typescript + * import { onNotificationReceived } from '@tauri-apps/plugin-notification'; + * const unlisten = await onNotificationReceived((notification) => { + * console.log(`received notification: ${notification.title}`); + * }); + * ``` + * + * @param cb The closure called with the notification that was delivered. + * + * @returns A promise resolving to a listener that can be used to stop listening for the event. + * + * @since 2.0.0 + */ async function onNotificationReceived( cb: (notification: Options) => void ): Promise { return await addPluginListener('notification', 'notification', cb) } +/** + * Listens to the actions the user performs on a notification. + * + * Only emitted on mobile, for notifications that reference an action type + * registered with {@link registerActionTypes}. + * + * @example + * ```typescript + * import { onAction } from '@tauri-apps/plugin-notification'; + * const unlisten = await onAction((notification) => { + * console.log(`user acted on notification: ${notification.title}`); + * }); + * ``` + * + * @param cb The closure called with the notification the action was performed on. + * + * @returns A promise resolving to a listener that can be used to stop listening for the event. + * + * @since 2.0.0 + */ async function onAction( cb: (notification: Options) => void ): Promise { diff --git a/plugins/notification/guest-js/init.ts b/plugins/notification/guest-js/init.ts index 42d65fd99..9e8933e48 100644 --- a/plugins/notification/guest-js/init.ts +++ b/plugins/notification/guest-js/init.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Webview script injected by the notification plugin. + * + * It replaces `window.Notification` with an implementation backed by the plugin, so the + * standard Notification Web API sends OS notifications, and keeps + * `window.Notification.permission` and `window.Notification.requestPermission` in sync + * with the permission state reported by the plugin. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' import type { PermissionState } from '@tauri-apps/api/core' import type { Options } from './index' diff --git a/plugins/notification/src/desktop.rs b/plugins/notification/src/desktop.rs index 4ceb83088..5b41a6c4b 100644 --- a/plugins/notification/src/desktop.rs +++ b/plugins/notification/src/desktop.rs @@ -10,6 +10,7 @@ use tauri::{ use crate::NotificationBuilder; +/// Initializes the desktop implementation of the notification APIs. pub fn init( app: &AppHandle, _api: PluginApi, @@ -23,6 +24,22 @@ pub fn init( pub struct Notification(AppHandle); impl crate::NotificationBuilder { + /// Shows the notification. + /// + /// When no title was set with [`Self::title`], the `productName` from the Tauri configuration is used instead. + /// Only the title, body, icon and sound of the notification are used on desktop; + /// the scheduling, grouping and action related options are ignored. + /// + /// The notification is dispatched on a background task, so this returns as soon as the payload is prepared. + /// + /// # Errors + /// + /// Returns an error when the notification could not be prepared, + /// e.g. when the path of the running executable cannot be resolved on Windows. + /// + /// ## Platform-specific + /// + /// - **Windows**: Not supported on Windows 7 unless the `windows7-compat` Cargo feature is enabled. pub fn show(self) -> crate::Result<()> { let mut notification = imp::Notification::new(self.app.config().identifier.clone()); @@ -54,14 +71,38 @@ impl crate::NotificationBuilder { } impl Notification { + /// Creates a new builder for a notification. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` pub fn builder(&self) -> NotificationBuilder { NotificationBuilder::new(self.0.clone()) } + /// Requests the permission to send notifications. + /// + /// Desktop applications do not need to ask for this permission, + /// so this always resolves to [`PermissionState::Granted`] without prompting the user. pub fn request_permission(&self) -> crate::Result { Ok(PermissionState::Granted) } + /// Checks whether the permission to send notifications was granted. + /// + /// Desktop applications do not need to ask for this permission, + /// so this always resolves to [`PermissionState::Granted`]. pub fn permission_state(&self) -> crate::Result { Ok(PermissionState::Granted) } diff --git a/plugins/notification/src/error.rs b/plugins/notification/src/error.rs index 339e763b1..f43da5823 100644 --- a/plugins/notification/src/error.rs +++ b/plugins/notification/src/error.rs @@ -4,12 +4,20 @@ use serde::{ser::Serializer, Serialize}; +/// Alias for a [`std::result::Result`] with the error type set to [`Error`]. pub type Result = std::result::Result; +/// Errors returned by the notification plugin. +/// +/// The error is serialized to its [`Display`](std::fmt::Display) string when it crosses the IPC boundary. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O operation failed, e.g. resolving the path of the running executable on Windows. #[error(transparent)] Io(#[from] std::io::Error), + /// Failed to run a command on the mobile plugin implementation (Kotlin on Android, Swift on iOS). + /// + /// Only available on mobile. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 7a0062bac..32c2e61ec 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -75,10 +75,13 @@ impl NotificationBuilder { self } - /// Identifier of the {@link Channel} that deliveres this notification. + /// Sets the identifier of the notification channel that delivers this notification. /// /// If the channel does not exist, the notification won't fire. - /// Make sure the channel exists with {@link listChannels} and {@link createChannel}. + /// Make sure the channel exists with `Notification::list_channels` and + /// `Notification::create_channel`. + /// + /// Only used on Android. pub fn channel_id(mut self, id: impl Into) -> Self { self.data.channel_id.replace(id.into()); self @@ -213,6 +216,22 @@ impl NotificationBuilder { /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the notification APIs. pub trait NotificationExt { + /// Returns the notification APIs managed by the plugin. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` fn notification(&self) -> &Notification; } diff --git a/plugins/notification/src/mobile.rs b/plugins/notification/src/mobile.rs index edfef728a..250532d4a 100644 --- a/plugins/notification/src/mobile.rs +++ b/plugins/notification/src/mobile.rs @@ -18,7 +18,8 @@ const PLUGIN_IDENTIFIER: &str = "app.tauri.notification"; #[cfg(target_os = "ios")] tauri::ios_plugin_binding!(init_plugin_notification); -// initializes the Kotlin or Swift plugin classes +/// Initializes the mobile implementation of the notification APIs by registering +/// the Kotlin (Android) or Swift (iOS) plugin class. pub fn init( _app: &AppHandle, api: PluginApi, @@ -31,6 +32,12 @@ pub fn init( } impl crate::NotificationBuilder { + /// Shows the notification, or schedules it when [`Self::schedule`] was called. + /// + /// # Errors + /// + /// Returns [`Error::PluginInvoke`](crate::Error::PluginInvoke) when the mobile plugin + /// rejects the notification, e.g. when the scheduled date is in the past. pub fn show(self) -> crate::Result<()> { self.handle .run_mobile_plugin::("show", self.data) @@ -45,10 +52,29 @@ impl crate::NotificationBuilder { pub struct Notification(PluginHandle); impl Notification { + /// Creates a new builder for a notification. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_notification::NotificationExt; + /// + /// fn notify(app: &tauri::AppHandle) { + /// app.notification() + /// .builder() + /// .title("Tauri") + /// .body("Tauri is awesome!") + /// .show() + /// .unwrap(); + /// } + /// ``` pub fn builder(&self) -> crate::NotificationBuilder { crate::NotificationBuilder::new(self.0.clone()) } + /// Requests the permission to send notifications, prompting the user when it was not decided yet. + /// + /// On Android this requests the `POST_NOTIFICATIONS` runtime permission. pub fn request_permission(&self) -> crate::Result { self.0 .run_mobile_plugin::("requestPermissions", ()) @@ -56,6 +82,7 @@ impl Notification { .map_err(Into::into) } + /// Checks the current state of the permission to send notifications without prompting the user. pub fn permission_state(&self) -> crate::Result { self.0 .run_mobile_plugin::("checkPermissions", ()) @@ -63,6 +90,13 @@ impl Notification { .map_err(Into::into) } + /// Registers the action types a notification can reference + /// through [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id). + /// + /// ## Platform-specific + /// + /// - **Android**: only the identifier, title and input flag of each [`Action`] are used. + /// - **iOS**: each action type is registered as a `UNNotificationCategory`. pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { let mut args = HashMap::new(); args.insert("types", types); @@ -71,6 +105,9 @@ impl Notification { .map_err(Into::into) } + /// Removes the delivered notifications with the given identifiers from the notification center. + /// + /// Use [`Self::remove_all_active`] to remove every delivered notification. pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { let mut args = HashMap::new(); args.insert( @@ -89,18 +126,21 @@ impl Notification { .map_err(Into::into) } + /// Lists the notifications that were delivered and are still visible in the notification center. pub fn active(&self) -> crate::Result> { self.0 .run_mobile_plugin("getActive", ()) .map_err(Into::into) } + /// Removes all delivered notifications from the notification center. pub fn remove_all_active(&self) -> crate::Result<()> { self.0 .run_mobile_plugin("removeActive", ()) .map_err(Into::into) } + /// Lists the scheduled notifications that have not been delivered yet. pub fn pending(&self) -> crate::Result> { self.0 .run_mobile_plugin("getPending", ()) @@ -119,6 +159,12 @@ impl Notification { self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) } + /// Creates a notification channel, which notifications can target + /// through [`NotificationBuilder::channel_id`](crate::NotificationBuilder::channel_id). + /// + /// Notifications that reference a channel that does not exist are not delivered. + /// + /// Only available on Android. #[cfg(target_os = "android")] pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { self.0 @@ -126,6 +172,9 @@ impl Notification { .map_err(Into::into) } + /// Deletes the notification channel with the given identifier. + /// + /// Only available on Android. #[cfg(target_os = "android")] pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { let mut args = HashMap::new(); @@ -135,6 +184,9 @@ impl Notification { .map_err(Into::into) } + /// Lists the notification channels that are currently registered for the app. + /// + /// Only available on Android. #[cfg(target_os = "android")] pub fn list_channels(&self) -> crate::Result> { self.0 diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index e93b1e078..ce5993a0f 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -8,6 +8,9 @@ use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializ use url::Url; +/// A media file attached to a notification. +/// +/// Attachments are only used on mobile; desktop notifications ignore them. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachment { @@ -16,32 +19,65 @@ pub struct Attachment { } impl Attachment { + /// Creates a new attachment with the given identifier and URL. + /// + /// The URL accepts the `asset` and `file` protocols. pub fn new(id: impl Into, url: Url) -> Self { Self { id: id.into(), url } } } +/// The set of date fields a notification must match to be delivered. +/// +/// Fields left as [`None`] match any value, so the notification fires on every date +/// whose remaining components match. Used by [`Schedule::Interval`]. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScheduleInterval { + /// The year the notification fires on. pub year: Option, + /// The month of the year the notification fires on. pub month: Option, + /// The day of the month the notification fires on. pub day: Option, + /// The day of the week the notification fires on. + /// + /// 1 - Sunday, 2 - Monday, 3 - Tuesday, 4 - Wednesday, 5 - Thursday, 6 - Friday, 7 - Saturday. pub weekday: Option, + /// The hour of the day the notification fires on, in the 24-hour clock. pub hour: Option, + /// The minute of the hour the notification fires on. pub minute: Option, + /// The second of the minute the notification fires on. pub second: Option, } +/// The unit of the repeating interval used by [`Schedule::Every`]. +/// +/// It is serialized as its lowercase camelCase name, e.g. `twoWeeks`. #[derive(Debug)] pub enum ScheduleEvery { + /// Repeats every year. + /// + /// On Android a year is approximated as 52 weeks. Year, + /// Repeats every month. + /// + /// On Android a month is approximated as 30 days. Month, + /// Repeats every two weeks. TwoWeeks, + /// Repeats every week. Week, + /// Repeats every day. Day, + /// Repeats every hour. Hour, + /// Repeats every minute. Minute, + /// Repeats every second. + /// + /// Not supported on iOS, where repeating triggers must be at least a minute apart. Second, } @@ -93,31 +129,58 @@ impl<'de> Deserialize<'de> for ScheduleEvery { } } +/// Defines when a notification is delivered. +/// +/// Scheduling is only implemented on mobile; the desktop implementation delivers the +/// notification immediately and ignores the schedule. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Schedule { + /// Fires at a specific date and time, which must be in the future. #[serde(rename_all = "camelCase")] At { + /// The date and time the notification fires at, serialized as an ISO-8601 string. #[serde( serialize_with = "iso8601::serialize", deserialize_with = "time::serde::iso8601::deserialize" )] date: time::OffsetDateTime, + /// Whether the notification keeps repeating, using the duration between the moment it is + /// scheduled and `date` as the interval. Defaults to `false`. + /// + /// The interval must be at least one minute on iOS. #[serde(default)] repeating: bool, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. #[serde(default)] allow_while_idle: bool, }, + /// Fires whenever the current date matches every field set on the given interval. #[serde(rename_all = "camelCase")] Interval { + /// The date fields the current date must match for the notification to fire. interval: ScheduleInterval, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. #[serde(default)] allow_while_idle: bool, }, + /// Fires repeatedly, once every `count` times the given interval unit. #[serde(rename_all = "camelCase")] Every { + /// The unit of the repeating interval. interval: ScheduleEvery, + /// How many interval units elapse between each notification. count: u8, + /// Whether the notification is allowed to fire while the device is in low-power idle + /// (Doze) mode. Defaults to `false`. + /// + /// Only used on Android. #[serde(default)] allow_while_idle: bool, }, @@ -145,6 +208,10 @@ mod iso8601 { } } +/// The payload of a notification, as sent to the platform implementation. +/// +/// Build it with [`NotificationBuilder`](crate::NotificationBuilder) rather than constructing it directly. +/// The identifier defaults to a random 32-bit integer when it is not provided. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NotificationData { @@ -209,6 +276,9 @@ impl Default for NotificationData { } } +/// A notification that was scheduled and has not been delivered yet. +/// +/// Returned by `Notification::pending`, which is only available on mobile. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PendingNotification { @@ -219,23 +289,32 @@ pub struct PendingNotification { } impl PendingNotification { + /// The notification identifier. pub fn id(&self) -> i32 { self.id } + /// The notification title, if it was set. pub fn title(&self) -> Option<&str> { self.title.as_deref() } + /// The notification body, if it was set. pub fn body(&self) -> Option<&str> { self.body.as_deref() } + /// The schedule that determines when the notification is delivered. pub fn schedule(&self) -> &Schedule { &self.schedule } } +/// A notification that was delivered and is still visible in the notification center. +/// +/// Returned by `Notification::active`, which is only available on mobile. +/// Which fields are populated depends on the platform, since Android and iOS expose +/// different information about delivered notifications. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActiveNotification { @@ -258,55 +337,88 @@ pub struct ActiveNotification { } impl ActiveNotification { + /// The notification identifier. pub fn id(&self) -> i32 { self.id } + /// The tag the notification was posted with. + /// + /// Only set on Android. pub fn tag(&self) -> Option<&str> { self.tag.as_deref() } + /// The notification title, if it was set. pub fn title(&self) -> Option<&str> { self.title.as_deref() } + /// The notification body, if it was set. pub fn body(&self) -> Option<&str> { self.body.as_deref() } + /// The identifier of the group the notification belongs to. + /// + /// Only set on Android. pub fn group(&self) -> Option<&str> { self.group.as_deref() } + /// Whether the notification is the summary of its group. + /// + /// Only set on Android. Defaults to `false`. pub fn group_summary(&self) -> bool { self.group_summary } + /// The platform extras attached to the notification, as string values. + /// + /// Only set on Android, where it holds the `android.app.Notification` extras bundle. pub fn data(&self) -> &HashMap { &self.data } + /// The extra payload that was stored in the notification. pub fn extra(&self) -> &HashMap { &self.extra } + /// The attachments of the notification. + /// + /// Only set on iOS. pub fn attachments(&self) -> &[Attachment] { &self.attachments } + /// The identifier of the action type the notification was registered with. + /// + /// Only set on iOS. pub fn action_type_id(&self) -> Option<&str> { self.action_type_id.as_deref() } + /// The schedule the notification was delivered with, if it was scheduled. pub fn schedule(&self) -> Option<&Schedule> { self.schedule.as_ref() } + /// The sound resource name of the notification. + /// + /// Only set on iOS. pub fn sound(&self) -> Option<&str> { self.sound.as_deref() } } +/// A group of [`Action`]s a notification can display, referenced by +/// [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id). +/// +/// Register it with `Notification::register_action_types` before sending a notification that uses it. +/// It maps to a `UNNotificationCategory` on iOS and to an action group on Android. +/// +/// Only available on mobile. Use [`ActionType::builder`] to construct one. #[cfg(mobile)] #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -320,12 +432,19 @@ pub struct ActionType { hidden_previews_show_subtitle: bool, } +/// Builder for an [`ActionType`], created with [`ActionType::builder`]. +/// +/// Only available on mobile. #[cfg(mobile)] #[derive(Debug)] pub struct ActionTypeBuilder(ActionType); #[cfg(mobile)] impl ActionType { + /// Creates a builder for an action type with the given identifier. + /// + /// All the optional settings default to `false` or [`None`]; + /// call [`ActionTypeBuilder::build`] to get the [`ActionType`]. pub fn builder(id: impl Into) -> ActionTypeBuilder { ActionTypeBuilder(Self { id: id.into(), @@ -338,30 +457,47 @@ impl ActionType { }) } + /// The identifier of this action type. pub fn id(&self) -> &str { &self.id } + /// The actions associated with this action type. pub fn actions(&self) -> &[Action] { &self.actions } + /// The placeholder shown instead of the notification body when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_body_placeholder(&self) -> Option<&str> { self.hidden_previews_body_placeholder.as_deref() } + /// Whether the app is notified when the user dismisses the notification. + /// + /// Only used on iOS. pub fn custom_dismiss_action(&self) -> bool { self.custom_dismiss_action } + /// Whether the notification can be displayed in a CarPlay environment. + /// + /// Only used on iOS. pub fn allow_in_car_play(&self) -> bool { self.allow_in_car_play } + /// Whether the notification title is shown even when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_show_title(&self) -> bool { self.hidden_previews_show_title } + /// Whether the notification subtitle is shown even when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_show_subtitle(&self) -> bool { self.hidden_previews_show_subtitle } @@ -369,11 +505,15 @@ impl ActionType { #[cfg(mobile)] impl ActionTypeBuilder { + /// Sets the actions associated with this action type. pub fn actions(mut self, actions: Vec) -> Self { self.0.actions = actions; self } + /// Sets the placeholder shown instead of the notification body when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_body_placeholder( mut self, hidden_previews_body_placeholder: impl Into, @@ -384,31 +524,50 @@ impl ActionTypeBuilder { self } + /// Sets whether the app is notified when the user dismisses the notification. + /// + /// Only used on iOS. pub fn custom_dismiss_action(mut self, custom_dismiss_action: bool) -> Self { self.0.custom_dismiss_action = custom_dismiss_action; self } + /// Sets whether the notification can be displayed in a CarPlay environment. + /// + /// Only used on iOS. pub fn allow_in_car_play(mut self, allow_in_car_play: bool) -> Self { self.0.allow_in_car_play = allow_in_car_play; self } + /// Sets whether the notification title is shown even when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_show_title(mut self, hidden_previews_show_title: bool) -> Self { self.0.hidden_previews_show_title = hidden_previews_show_title; self } + /// Sets whether the notification subtitle is shown even when previews are hidden. + /// + /// Only used on iOS. pub fn hidden_previews_show_subtitle(mut self, hidden_previews_show_subtitle: bool) -> Self { self.0.hidden_previews_show_subtitle = hidden_previews_show_subtitle; self } + /// Builds the [`ActionType`]. pub fn build(self) -> ActionType { self.0 } } +/// A button the user can tap on a notification, belonging to an [`ActionType`]. +/// +/// It maps to a `UNNotificationAction` on iOS. On Android only the identifier, the title +/// and the input flag are used. +/// +/// Only available on mobile. Use [`Action::builder`] to construct one. #[cfg(mobile)] #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -423,12 +582,19 @@ pub struct Action { input_placeholder: Option, } +/// Builder for an [`Action`], created with [`Action::builder`]. +/// +/// Only available on mobile. #[cfg(mobile)] #[derive(Debug)] pub struct ActionBuilder(Action); #[cfg(mobile)] impl Action { + /// Creates a builder for an action with the given identifier and button title. + /// + /// All the optional settings default to `false` or [`None`]; + /// call [`ActionBuilder::build`] to get the [`Action`]. pub fn builder(id: impl Into, title: impl Into) -> ActionBuilder { ActionBuilder(Self { id: id.into(), @@ -442,34 +608,52 @@ impl Action { }) } + /// The identifier of this action, reported back when the user triggers it. pub fn id(&self) -> &str { &self.id } + /// The text displayed on the action button. pub fn title(&self) -> &str { &self.title } + /// Whether the device must be unlocked for the action to run. + /// + /// Only used on iOS. pub fn requires_authentication(&self) -> bool { self.requires_authentication } + /// Whether the app is brought to the foreground when the action is triggered. + /// + /// Only used on iOS. pub fn foreground(&self) -> bool { self.foreground } + /// Whether the action is displayed as destructive, usually in red. + /// + /// Only used on iOS. pub fn destructive(&self) -> bool { self.destructive } + /// Whether triggering the action lets the user type a text response. pub fn input(&self) -> bool { self.input } + /// The text displayed on the button that submits the text input. + /// + /// Only used on iOS. pub fn input_button_title(&self) -> Option<&str> { self.input_button_title.as_deref() } + /// The placeholder displayed on the empty text input field. + /// + /// Only used on iOS. pub fn input_placeholder(&self) -> Option<&str> { self.input_placeholder.as_deref() } @@ -477,36 +661,53 @@ impl Action { #[cfg(mobile)] impl ActionBuilder { + /// Sets whether the device must be unlocked for the action to run. + /// + /// Only used on iOS. pub fn requires_authentication(mut self, requires_authentication: bool) -> Self { self.0.requires_authentication = requires_authentication; self } + /// Sets whether the app is brought to the foreground when the action is triggered. + /// + /// Only used on iOS. pub fn foreground(mut self, foreground: bool) -> Self { self.0.foreground = foreground; self } + /// Sets whether the action is displayed as destructive, usually in red. + /// + /// Only used on iOS. pub fn destructive(mut self, destructive: bool) -> Self { self.0.destructive = destructive; self } + /// Sets whether triggering the action lets the user type a text response. pub fn input(mut self, input: bool) -> Self { self.0.input = input; self } + /// Sets the text displayed on the button that submits the text input. + /// + /// Only used on iOS. pub fn input_button_title(mut self, input_button_title: impl Into) -> Self { self.0.input_button_title.replace(input_button_title.into()); self } + /// Sets the placeholder displayed on the empty text input field. + /// + /// Only used on iOS. pub fn input_placeholder(mut self, input_placeholder: impl Into) -> Self { self.0.input_placeholder.replace(input_placeholder.into()); self } + /// Builds the [`Action`]. pub fn build(self) -> Action { self.0 } @@ -520,13 +721,24 @@ mod android { use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; + /// How much the notifications of a [`Channel`] interrupt the user. + /// + /// It maps to the `NotificationManager.IMPORTANCE_*` constants and is serialized as its + /// integer value. Only available on Android. #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] #[repr(u8)] pub enum Importance { + /// The notifications are not shown. None = 0, + /// The notifications are only shown in the shade, below the fold, without a status bar icon. Min = 1, + /// The notifications are shown without a sound. Low = 2, + /// The notifications are shown and make a sound. + /// + /// This is the value used when the channel does not define an importance. Default = 3, + /// The notifications are shown, make a sound and pop up as a heads-up notification. High = 4, } @@ -536,14 +748,29 @@ mod android { } } + /// How much of a notification is shown on the lock screen. + /// + /// It maps to the `Notification.VISIBILITY_*` constants and is serialized as its + /// integer value. Only available on Android. #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr)] #[repr(i8)] pub enum Visibility { + /// The notification is not shown on the lock screen at all. Secret = -1, + /// The notification is shown on the lock screen with its sensitive content hidden. + /// + /// This is the value used when the channel does not define a visibility. Private = 0, + /// The notification is shown in full on the lock screen. Public = 1, } + /// A notification channel, the category users configure notification behavior on. + /// + /// Notifications reference a channel through + /// [`NotificationBuilder::channel_id`](crate::NotificationBuilder::channel_id) and are not + /// delivered when the channel does not exist. Only available on Android. + /// Use [`Channel::builder`] to construct one. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Channel { @@ -558,10 +785,18 @@ mod android { visibility: Option, } + /// Builder for a [`Channel`], created with [`Channel::builder`]. + /// + /// Only available on Android. #[derive(Debug)] pub struct ChannelBuilder(Channel); impl Channel { + /// Creates a builder for a channel with the given identifier and user visible name. + /// + /// Lights and vibration are disabled, the importance defaults to [`Importance::Default`] + /// and the remaining settings default to [`None`]; + /// call [`ChannelBuilder::build`] to get the [`Channel`]. pub fn builder(id: impl Into, name: impl Into) -> ChannelBuilder { ChannelBuilder(Self { id: id.into(), @@ -576,79 +811,102 @@ mod android { }) } + /// The identifier of this channel. pub fn id(&self) -> &str { &self.id } + /// The user visible name of this channel. pub fn name(&self) -> &str { &self.name } + /// The user visible description of this channel. pub fn description(&self) -> Option<&str> { self.description.as_deref() } + /// The name of the sound resource played by the notifications of this channel. + /// + /// The resource must be placed in the app's `res/raw` folder. pub fn sound(&self) -> Option<&str> { self.sound.as_deref() } + /// Whether the notifications of this channel blink the device light. pub fn lights(&self) -> bool { self.lights } + /// The color of the device light, as a color string such as `#ff0000`. pub fn light_color(&self) -> Option<&str> { self.light_color.as_deref() } + /// Whether the notifications of this channel vibrate the device. pub fn vibration(&self) -> bool { self.vibration } + /// How much the notifications of this channel interrupt the user. pub fn importance(&self) -> Importance { self.importance } + /// How much of the notifications of this channel is shown on the lock screen. + /// + /// [`Visibility::Private`] is used when this is [`None`]. pub fn visibility(&self) -> Option { self.visibility } } impl ChannelBuilder { + /// Sets the user visible description of the channel. pub fn description(mut self, description: impl Into) -> Self { self.0.description.replace(description.into()); self } + /// Sets the name of the sound resource played by the notifications of this channel. + /// + /// The resource must be placed in the app's `res/raw` folder. pub fn sound(mut self, sound: impl Into) -> Self { self.0.sound.replace(sound.into()); self } + /// Sets whether the notifications of this channel blink the device light. pub fn lights(mut self, lights: bool) -> Self { self.0.lights = lights; self } + /// Sets the color of the device light, as a color string such as `#ff0000`. pub fn light_color(mut self, color: impl Into) -> Self { self.0.light_color.replace(color.into()); self } + /// Sets whether the notifications of this channel vibrate the device. pub fn vibration(mut self, vibration: bool) -> Self { self.0.vibration = vibration; self } + /// Sets how much the notifications of this channel interrupt the user. pub fn importance(mut self, importance: Importance) -> Self { self.0.importance = importance; self } + /// Sets how much of the notifications of this channel is shown on the lock screen. pub fn visibility(mut self, visibility: Visibility) -> Self { self.0.visibility.replace(visibility); self } + /// Builds the [`Channel`]. pub fn build(self) -> Channel { self.0 } diff --git a/plugins/opener/guest-js/init.ts b/plugins/opener/guest-js/init.ts index 4c2b631fb..d01238625 100644 --- a/plugins/opener/guest-js/init.ts +++ b/plugins/opener/guest-js/init.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Webview script injected by the opener plugin on every page load. + * + * It installs a `click` listener on `window` that intercepts clicks on `` elements whose + * `target` is `_blank` (or that are clicked while holding `Ctrl` or `Shift`) and whose `href` + * uses the `http:`, `https:`, `mailto:` or `tel:` protocol, cancels the navigation and opens the + * link with the system's default browser through the `plugin:opener|open_url` command instead. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' // open links with the API diff --git a/plugins/opener/src/error.rs b/plugins/opener/src/error.rs index 23cd2a8aa..05642fb3f 100644 --- a/plugins/opener/src/error.rs +++ b/plugins/opener/src/error.rs @@ -6,37 +6,61 @@ use std::path::PathBuf; use serde::{Serialize, Serializer}; +/// The error type returned by the opener plugin's commands and APIs. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// Forwarded from a failed call into the mobile plugin runtime. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// Forwarded from a [`tauri::Error`], e.g. when resolving a scoped path fails. #[error(transparent)] Tauri(#[from] tauri::Error), + /// Forwarded from an [`std::io::Error`] raised while opening or revealing a path. #[error(transparent)] Io(#[from] std::io::Error), + /// Forwarded from a [`serde_json::Error`] raised while (de)serializing a mobile plugin payload. #[error(transparent)] Json(#[from] serde_json::Error), + /// The program passed as `with` is not a program name known by the underlying opener. #[error("unknown program {0}")] UnknownProgramName(String), + /// The path is not allowed by the opener scope, optionally together with the program it was requested to be opened with. #[error("Not allowed to open path {}{}", .path, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())] - ForbiddenPath { path: String, with: Option }, + ForbiddenPath { + /// The path that was rejected by the scope. + path: String, + /// The program the path was requested to be opened with, if any. + with: Option, + }, + /// The URL is not allowed by the opener scope, optionally together with the program it was requested to be opened with. #[error("Not allowed to open url {}{}", .url, .with.as_ref().map(|w| format!(" with {w}")).unwrap_or_default())] - ForbiddenUrl { url: String, with: Option }, + ForbiddenUrl { + /// The URL that was rejected by the scope. + url: String, + /// The program the URL was requested to be opened with, if any. + with: Option, + }, + /// The requested API is not supported on the current platform, e.g. [`crate::reveal_item_in_dir`] on Android and iOS. #[error("API not supported on the current platform")] UnsupportedPlatform, + /// Forwarded from a Win32 API call, see [`windows::core::Error`]. #[error(transparent)] #[cfg(windows)] Win32Error(#[from] windows::core::Error), + /// The given path has no parent directory, so it cannot be revealed in its containing folder. #[error("Path doesn't have a parent: {0}")] NoParent(PathBuf), // TODO: Add the underlying io::Error to this variant + /// Failed to convert the path to a Windows `ITEMIDLIST` while preparing it to be revealed in the file explorer. #[cfg(windows)] #[error("Failed to convert path '{0}' to ITEMIDLIST")] FailedToConvertPathToItemIdList(PathBuf), + /// Failed to convert the path to a `file://` URL, which is required to reveal it via D-Bus on Linux and BSD. #[error("Failed to convert path to file:// url")] FailedToConvertPathToFileUrl, + /// Forwarded from a [`zbus::Error`] raised while talking to the file manager or the desktop portal over D-Bus. #[error(transparent)] #[cfg(any( target_os = "linux", diff --git a/plugins/opener/src/lib.rs b/plugins/opener/src/lib.rs index 622a65097..13f6efb85 100644 --- a/plugins/opener/src/lib.rs +++ b/plugins/opener/src/lib.rs @@ -2,6 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Open files and URLs using their default application, and reveal files in the system's file explorer. +//! +//! Use the [`OpenerExt`] trait to access [`Opener::open_url`] and [`Opener::open_path`] from a +//! running Tauri app; the plugin's `open_url` and `open_path` commands enforce the scope +//! configured for the plugin before delegating to them. The [`open_url`] and [`open_path`] free +//! functions and the [`Opener`] methods themselves do not perform any scope check. +//! [`reveal_item_in_dir`] and [`reveal_items_in_dir`] return [`Error::UnsupportedPlatform`] on +//! Android and iOS. + use std::path::Path; use tauri::{plugin::TauriPlugin, Manager, Runtime}; @@ -29,6 +38,9 @@ type Result = std::result::Result; pub use open::{open_path, open_url}; pub use reveal_item_in_dir::{reveal_item_in_dir, reveal_items_in_dir}; +/// Access to the opener APIs, managed by the plugin as app state. +/// +/// Obtain an instance via [`OpenerExt::opener`]. pub struct Opener { // we use `fn() -> R` to silence the unused generic error // while keeping this struct `Send + Sync` without requiring `R` to be @@ -153,10 +165,20 @@ impl Opener { .map_err(Into::into) } + /// Reveal a path in the system's default explorer. See [`reveal_item_in_dir`] for details. + /// + /// ## Platform-specific: + /// + /// - **Android / iOS:** Unsupported, returns [`Error::UnsupportedPlatform`]. pub fn reveal_item_in_dir>(&self, p: P) -> Result<()> { reveal_item_in_dir(p) } + /// Reveal multiple paths in the system's default explorer. See [`reveal_items_in_dir`] for details. + /// + /// ## Platform-specific: + /// + /// - **Android / iOS:** Unsupported, returns [`Error::UnsupportedPlatform`]. pub fn reveal_items_in_dir(&self, paths: I) -> Result<()> where I: IntoIterator, @@ -168,6 +190,7 @@ impl Opener { /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the opener APIs. pub trait OpenerExt { + /// Returns the [`Opener`] instance managed by the plugin. fn opener(&self) -> &Opener; } diff --git a/plugins/os/guest-js/index.ts b/plugins/os/guest-js/index.ts index 697ae8ed3..21a5777b2 100644 --- a/plugins/os/guest-js/index.ts +++ b/plugins/os/guest-js/index.ts @@ -25,6 +25,9 @@ declare global { } } +/** + * A string describing the specific operating system in use, as returned by {@link platform}. + */ type Platform = | 'linux' | 'macos' @@ -37,8 +40,14 @@ type Platform = | 'android' | 'windows' +/** + * A coarse-grained operating system category, as returned by {@link type}. + */ type OsType = 'linux' | 'windows' | 'macos' | 'ios' | 'android' +/** + * A string describing the specific operating system architecture in use, as returned by {@link arch}. + */ type Arch = | 'x86' | 'x86_64' @@ -57,6 +66,13 @@ type Arch = * - `\n` on POSIX * - `\r\n` on Windows * + * @example + * ```typescript + * import { eol } from '@tauri-apps/plugin-os'; + * const eolChar = eol(); + * ``` + * + * @returns The end-of-line marker for the current platform. * @since 2.0.0 * */ function eol(): string { @@ -73,6 +89,7 @@ function eol(): string { * const platformName = platform(); * ``` * + * @returns The platform name. * @since 2.0.0 * */ @@ -88,12 +105,16 @@ function platform(): Platform { * const osVersion = version(); * ``` * + * @returns The operating system version. * @since 2.0.0 */ function version(): string { return window.__TAURI_OS_PLUGIN_INTERNALS__.version } +/** + * A string describing the operating system family, as returned by {@link family}. + */ type Family = 'unix' | 'windows' /** @@ -104,6 +125,7 @@ type Family = 'unix' | 'windows' * const family = family(); * ``` * + * @returns The operating system family. * @since 2.0.0 */ function family(): Family { @@ -118,6 +140,7 @@ function family(): Family { * const osType = type(); * ``` * + * @returns The operating system type. * @since 2.0.0 */ function type(): OsType { @@ -133,6 +156,7 @@ function type(): OsType { * const archName = arch(); * ``` * + * @returns The operating system architecture. * @since 2.0.0 */ function arch(): Arch { @@ -147,6 +171,7 @@ function arch(): Arch { * const exeExt = exeExtension(); * ``` * + * @returns The file extension used for executable binaries on this platform. * @since 2.0.0 */ function exeExtension(): string { @@ -164,6 +189,7 @@ function exeExtension(): string { * } * ``` * + * @returns A promise resolving to the `BCP-47` language tag, or `null` if it could not be obtained. * @since 2.0.0 */ async function locale(): Promise { @@ -177,6 +203,9 @@ async function locale(): Promise { * import { hostname } from '@tauri-apps/plugin-os'; * const hostname = await hostname(); * ``` + * + * @returns A promise resolving to the host name of the operating system. + * @since 2.0.0 */ async function hostname(): Promise { return await invoke('plugin:os|hostname') diff --git a/plugins/os/src/error.rs b/plugins/os/src/error.rs index f5d8816af..7c752ba03 100644 --- a/plugins/os/src/error.rs +++ b/plugins/os/src/error.rs @@ -4,6 +4,9 @@ use serde::{Serialize, Serializer}; +/// All errors that can occur while running the os plugin. +/// +/// This enum currently has no variants: none of the plugin's commands can fail. #[derive(Debug, thiserror::Error)] pub enum Error {} diff --git a/plugins/os/src/lib.rs b/plugins/os/src/lib.rs index 50ab89eea..853dbdf69 100644 --- a/plugins/os/src/lib.rs +++ b/plugins/os/src/lib.rs @@ -23,11 +23,17 @@ mod error; pub use error::Error; +/// The type of the current operating system, as returned by [`type_`]. pub enum OsType { + /// Linux and Linux-based systems such as FreeBSD, DragonFly BSD, NetBSD and OpenBSD. Linux, + /// Windows. Windows, + /// macOS. Macos, + /// iOS. IOS, + /// Android. Android, } @@ -127,6 +133,7 @@ impl InitJavascript<'_> { } } +/// Initializes the plugin. pub fn init() -> TauriPlugin { let init_js = InitJavascript::new() .render_default(&Default::default()) diff --git a/plugins/persisted-scope/src/lib.rs b/plugins/persisted-scope/src/lib.rs index 602d90864..96331651e 100644 --- a/plugins/persisted-scope/src/lib.rs +++ b/plugins/persisted-scope/src/lib.rs @@ -163,6 +163,14 @@ fn save_scopes(scope: &tauri::fs::Scope, app_dir: &Path, scope_state_path: &Path }); } +/// Initializes the plugin. +/// +/// On setup, this restores the filesystem scope, and the `asset://` protocol scope when the +/// `protocol-asset` feature is enabled, from the state persisted during a previous run, then +/// listens for further scope changes to persist them again. Scopes are stored under the app's +/// data directory; nothing is restored or persisted if that directory cannot be resolved. The +/// `fs` plugin must be registered before this plugin, otherwise the filesystem scope is not +/// restored or persisted (a warning is printed in debug builds). pub fn init() -> TauriPlugin { Builder::new("persisted-scope") .setup(|app, _api| { diff --git a/plugins/positioner/guest-js/index.ts b/plugins/positioner/guest-js/index.ts index 74d0295ec..ba3548078 100644 --- a/plugins/positioner/guest-js/index.ts +++ b/plugins/positioner/guest-js/index.ts @@ -3,6 +3,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Move the window to well-known positions, including positions relative to the tray icon. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' import type { TrayIconEvent } from '@tauri-apps/api/tray' @@ -10,20 +16,53 @@ import type { TrayIconEvent } from '@tauri-apps/api/tray' * Well known window positions. */ export enum Position { + /** Top left corner of the screen. */ TopLeft = 0, + /** Top right corner of the screen. */ TopRight, + /** Bottom left corner of the screen. */ BottomLeft, + /** Bottom right corner of the screen. */ BottomRight, + /** Top center of the screen. */ TopCenter, + /** Bottom center of the screen. */ BottomCenter, + /** Vertically centered on the left edge of the screen. */ LeftCenter, + /** Vertically centered on the right edge of the screen. */ RightCenter, + /** Center of the screen. */ Center, + /** + * Above the tray icon, aligned with its left edge. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ TrayLeft, + /** + * Directly below the tray icon, aligned with its left edge. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ TrayBottomLeft, + /** + * Above the tray icon, aligned with its right edge. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ TrayRight, + /** + * Directly below the tray icon, aligned with its right edge. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ TrayBottomRight, + /** + * Above the tray icon, horizontally centered on it. Requires the `tray-icon` feature and the + * tray icon's events to be forwarded through {@link handleIconState}. + */ TrayCenter, + /** + * Directly below the tray icon, horizontally centered on it. Requires the `tray-icon` feature + * and the tray icon's events to be forwarded through {@link handleIconState}. + */ TrayBottomCenter } @@ -31,7 +70,15 @@ export enum Position { * Moves the `Window` to the given {@link Position} using `WindowExt.move_window()` * All positions are relative to the **current** screen. * + * @example + * ```typescript + * import { moveWindow, Position } from '@tauri-apps/plugin-positioner' + * + * await moveWindow(Position.TopRight) + * ``` + * * @param to The {@link Position} to move to. + * @since 2.0.0 */ export async function moveWindow(to: Position): Promise { await invoke('plugin:positioner|move_window', { @@ -44,7 +91,16 @@ export async function moveWindow(to: Position): Promise { * * This move operation constrains the window to the screen dimensions in case of * tray-icon positions. + * + * @example + * ```typescript + * import { moveWindowConstrained, Position } from '@tauri-apps/plugin-positioner' + * + * await moveWindowConstrained(Position.TrayCenter) + * ``` + * * @param to The (tray) {@link Position} to move to. + * @since 2.1.0 */ export async function moveWindowConstrained(to: Position): Promise { await invoke('plugin:positioner|move_window_constrained', { @@ -52,6 +108,30 @@ export async function moveWindowConstrained(to: Position): Promise { }) } +/** + * Reports the tray icon's current position and size so the `Tray*` {@link Position} variants + * can be resolved. + * + * This is an internal helper meant to be called from the tray icon's event handler (the + * `action` callback passed to `TrayIcon.new()`), forwarding every {@link TrayIconEvent} it + * receives so that {@link moveWindow} and {@link moveWindowConstrained} can later position the + * window relative to the tray icon. + * + * @example + * ```typescript + * import { handleIconState } from '@tauri-apps/plugin-positioner' + * import { TrayIcon, type TrayIconEvent } from '@tauri-apps/api/tray' + * + * const action = async (event: TrayIconEvent) => { + * await handleIconState(event) + * } + * + * const tray = await TrayIcon.new({ id: 'main', action }) + * ``` + * + * @param event The tray icon event to read the position and size from. + * @since 2.0.0 + */ export async function handleIconState(event: TrayIconEvent): Promise { await invoke('plugin:positioner|set_tray_icon_state', { position: event.rect.position, diff --git a/plugins/positioner/src/ext.rs b/plugins/positioner/src/ext.rs index f9d6a80d3..a27c18a96 100644 --- a/plugins/positioner/src/ext.rs +++ b/plugins/positioner/src/ext.rs @@ -13,28 +13,53 @@ use tauri::Monitor; use tauri::{PhysicalPosition, PhysicalSize, Result, Runtime, WebviewWindow, Window}; /// Well known window positions. +/// +/// The `Tray*` variants require the `tray-icon` feature and only resolve once the tray icon has +/// reported its position (see `on_tray_event`); using one before that happens returns an error. #[derive(Debug, Deserialize_repr)] #[repr(u16)] pub enum Position { + /// Top left corner of the current screen. TopLeft = 0, + /// Top right corner of the current screen. TopRight, + /// Bottom left corner of the current screen. BottomLeft, + /// Bottom right corner of the current screen. BottomRight, + /// Top center of the current screen. TopCenter, + /// Bottom center of the current screen. BottomCenter, + /// Vertically centered on the left edge of the current screen. LeftCenter, + /// Vertically centered on the right edge of the current screen. RightCenter, + /// Center of the current screen. Center, + /// Above the tray icon, aligning the window's left edge with the tray icon's left edge. On + /// Windows and macOS the window moves below the icon instead when there is not enough room + /// above. #[cfg(feature = "tray-icon")] TrayLeft, + /// Directly below the tray icon, aligning the window's left edge with the tray icon's left + /// edge. #[cfg(feature = "tray-icon")] TrayBottomLeft, + /// Above the tray icon, aligning the window's left edge with the tray icon's right edge. On + /// Windows and macOS the window moves below the icon instead when there is not enough room + /// above. #[cfg(feature = "tray-icon")] TrayRight, + /// Directly below the tray icon, aligning the window's left edge with the tray icon's right + /// edge. #[cfg(feature = "tray-icon")] TrayBottomRight, + /// Above the tray icon, horizontally centered on it. On Windows and macOS the window moves + /// below the icon instead when there is not enough room above. #[cfg(feature = "tray-icon")] TrayCenter, + /// Directly below the tray icon, horizontally centered on it. #[cfg(feature = "tray-icon")] TrayBottomCenter, } diff --git a/plugins/positioner/src/lib.rs b/plugins/positioner/src/lib.rs index 8d9594028..fd5e82638 100644 --- a/plugins/positioner/src/lib.rs +++ b/plugins/positioner/src/lib.rs @@ -31,6 +31,16 @@ use tauri::{tray::TrayIconEvent, AppHandle, Manager, PhysicalPosition, PhysicalS #[cfg(feature = "tray-icon")] struct Tray(std::sync::Mutex, PhysicalSize)>>); +/// Records the tray icon's latest position and size so that the `Tray*` [`Position`] variants +/// (e.g. [`Position::TrayLeft`]) can be resolved by [`WindowExt::move_window`] and +/// [`WindowExt::move_window_constrained`]. +/// +/// Call this from your tray icon's event handler. Only [`TrayIconEvent::Click`], +/// [`TrayIconEvent::Enter`], [`TrayIconEvent::Leave`] and [`TrayIconEvent::Move`] carry the +/// icon's position and update the tracked value; other events are ignored. Until this has been +/// called at least once, moving a window to a `Tray*` position fails. +/// +/// Requires the `tray-icon` feature. #[cfg(feature = "tray-icon")] pub fn on_tray_event(app: &AppHandle, event: &TrayIconEvent) { let (position, size) = { diff --git a/plugins/process/src/lib.rs b/plugins/process/src/lib.rs index b83d89648..d7f05f481 100644 --- a/plugins/process/src/lib.rs +++ b/plugins/process/src/lib.rs @@ -16,6 +16,7 @@ use tauri::{ mod commands; +/// Initializes the plugin, exposing commands to exit or restart the current process. pub fn init() -> TauriPlugin { Builder::new("process") .invoke_handler(tauri::generate_handler![commands::exit, commands::restart]) diff --git a/plugins/shell/guest-js/index.ts b/plugins/shell/guest-js/index.ts index 081d54c13..afac3026e 100644 --- a/plugins/shell/guest-js/index.ts +++ b/plugins/shell/guest-js/index.ts @@ -66,6 +66,8 @@ import { invoke, Channel } from '@tauri-apps/api/core' /** + * Options that configure how a child process is spawned. + * * @since 2.0.0 */ interface SpawnOptions { @@ -87,6 +89,8 @@ interface InternalSpawnOptions extends SpawnOptions { } /** + * The output collected from a child process that ran to completion. + * * @since 2.0.0 */ interface ChildProcess { @@ -101,6 +105,9 @@ interface ChildProcess { } /** + * A minimal event emitter modeled after Node.js' `EventEmitter`, used by + * {@link Command} and by its `stdout` and `stderr` streams. + * * @since 2.0.0 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -113,6 +120,18 @@ class EventEmitter> { /** * Alias for `emitter.on(eventName, listener)`. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.addListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ addListener( @@ -125,6 +144,20 @@ class EventEmitter> { /** * Alias for `emitter.off(eventName, listener)`. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const listener = (error: string) => console.error(error); + * command.addListener('error', listener); + * command.removeListener('error', listener); + * ``` + * + * @param eventName The name of the event to stop listening to. + * @param listener The exact callback that was registered before. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ removeListener( @@ -142,6 +175,20 @@ class EventEmitter> { * * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('close', (data) => { + * console.log(`command finished with code ${data.code}`); + * }); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ on( @@ -164,6 +211,20 @@ class EventEmitter> { * * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.once('close', (data) => { + * console.log(`command finished with code ${data.code}`); + * }); + * ``` + * + * @param eventName The name of the event to listen to once. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ once( @@ -181,6 +242,20 @@ class EventEmitter> { * Removes the all specified listener from the listener array for the event eventName * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const listener = (error: string) => console.error(error); + * command.on('error', listener); + * command.off('error', listener); + * ``` + * + * @param eventName The name of the event to stop listening to. + * @param listener The exact callback that was registered before. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ off( @@ -201,6 +276,19 @@ class EventEmitter> { * * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('error', (error) => console.error(error)); + * command.removeAllListeners('error'); + * ``` + * + * @param event The name of the event to remove the listeners of. + * When omitted, the listeners of every event are removed. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ removeAllListeners(event?: N): this { @@ -215,10 +303,23 @@ class EventEmitter> { } /** - * @ignore - * Synchronously calls each of the listeners registered for the event named`eventName`, in the order they were registered, passing the supplied arguments + * Synchronously calls each of the listeners registered for the event named + * `eventName`, in the order they were registered, passing the supplied arguments * to each. * + * @ignore + * + * @example + * ```typescript + * import { EventEmitter } from '@tauri-apps/plugin-shell'; + * const emitter = new EventEmitter<{ data: string }>(); + * emitter.on('data', (line) => console.log(line)); + * emitter.emit('data', 'hello'); + * ``` + * + * @param eventName The name of the event to emit. + * @param arg The payload passed to every registered listener. + * * @returns `true` if the event had listeners, `false` otherwise. * * @since 2.0.0 @@ -236,6 +337,18 @@ class EventEmitter> { /** * Returns the number of listeners listening to the event named `eventName`. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.on('close', () => {}); + * console.log(command.listenerCount('close')); // 1 + * ``` + * + * @param eventName The name of the event to count the listeners of. + * + * @returns The number of listeners registered for the given event. + * * @since 2.0.0 */ listenerCount(eventName: N): number { @@ -253,6 +366,18 @@ class EventEmitter> { * * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.prependListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ prependListener( @@ -275,6 +400,18 @@ class EventEmitter> { * * Returns a reference to the `EventEmitter`, so that calls can be chained. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.prependOnceListener('error', (error) => console.error(error)); + * ``` + * + * @param eventName The name of the event to listen to once. + * @param listener The callback invoked with the event payload. + * + * @returns A reference to the `EventEmitter`, so that calls can be chained. + * * @since 2.0.0 */ prependOnceListener( @@ -292,12 +429,30 @@ class EventEmitter> { } /** + * A handle to a child process spawned with {@link Command.spawn}, + * which can be used to write to its `stdin` or to kill it. + * * @since 2.0.0 */ class Child { /** The child process `pid`. */ pid: number + /** + * Creates a handle to the child process with the given process id. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * // a `Child` is usually obtained by spawning a command: + * const child = await Command.create('node').spawn(); + * console.log(child.pid); + * ``` + * + * @param pid The process id of the child process. + * + * @since 2.0.0 + */ constructor(pid: number) { this.pid = pid } @@ -329,6 +484,14 @@ class Child { /** * Kills the child process. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * const child = await command.spawn(); + * await child.kill(); + * ``` + * * @returns A promise indicating the success or failure of the operation. * * @since 2.0.0 @@ -341,12 +504,25 @@ class Child { } } +/** + * The events emitted by a {@link Command} instance. + * + * @since 2.0.0 + */ interface CommandEvents { + /** Emitted when the child process terminated, carrying its exit code and signal. */ close: TerminatedPayload + /** Emitted when the child process could not be spawned or failed unexpectedly, carrying the error message. */ error: string } +/** + * The events emitted by the `stdout` and `stderr` streams of a {@link Command}. + * + * @since 2.0.0 + */ interface OutputEvents { + /** Emitted for each line the process wrote to the stream, or for each raw chunk when the `raw` encoding is used. */ data: O } @@ -403,20 +579,9 @@ class Command extends EventEmitter { this.options = options ?? {} } - static create(program: string, args?: string | string[]): Command - static create( - program: string, - args?: string | string[], - options?: SpawnOptions & { encoding: 'raw' } - ): Command - static create( - program: string, - args?: string | string[], - options?: SpawnOptions - ): Command - /** - * Creates a command to execute the given program. + * Creates a command to execute the given program, decoding its output as text. + * * @example * ```typescript * import { Command } from '@tauri-apps/plugin-shell'; @@ -426,6 +591,77 @@ class Command extends EventEmitter { * * @param program The program to execute. * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create(program: string, args?: string | string[]): Command + /** + * Creates a command to execute the given program, keeping its output as raw bytes. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('my-app', ['run', 'tauri'], { encoding: 'raw' }); + * const output = await command.execute(); + * console.log(output.stdout); // a Uint8Array + * ``` + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options using the `raw` encoding, which makes the process + * output be delivered as `Uint8Array` instead of `string`. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create( + program: string, + args?: string | string[], + options?: SpawnOptions & { encoding: 'raw' } + ): Command + /** + * Creates a command to execute the given program with the given spawn options. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('my-app', ['run', 'tauri'], { cwd: '/path/to/project' }); + * const output = await command.execute(); + * ``` + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ + static create( + program: string, + args?: string | string[], + options?: SpawnOptions + ): Command + + /** + * Creates a command to execute the given program. + * + * @param program The program to execute. + * It must be configured in your project's capabilities. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 */ static create( program: string, @@ -435,12 +671,74 @@ class Command extends EventEmitter { return new Command(program, args, options) } + /** + * Creates a command to execute the given sidecar program, decoding its output as text. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar'); + * const output = await command.execute(); + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ static sidecar(program: string, args?: string | string[]): Command + /** + * Creates a command to execute the given sidecar program, keeping its output as raw bytes. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar', [], { encoding: 'raw' }); + * const output = await command.execute(); + * console.log(output.stdout); // a Uint8Array + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options using the `raw` encoding, which makes the process + * output be delivered as `Uint8Array` instead of `string`. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ static sidecar( program: string, args?: string | string[], options?: SpawnOptions & { encoding: 'raw' } ): Command + /** + * Creates a command to execute the given sidecar program with the given spawn options. + * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.sidecar('my-sidecar', [], { cwd: '/path/to/project' }); + * const output = await command.execute(); + * ``` + * + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 + */ static sidecar( program: string, args?: string | string[], @@ -449,15 +747,17 @@ class Command extends EventEmitter { /** * Creates a command to execute the given sidecar program. - * @example - * ```typescript - * import { Command } from '@tauri-apps/plugin-shell'; - * const command = Command.sidecar('my-sidecar'); - * const output = await command.execute(); - * ``` * - * @param program The program to execute. - * It must be configured in your project's capabilities. + * @param program The sidecar program to execute. + * It must be configured in your project's capabilities + * and defined on `tauri.conf.json > bundle > externalBin`. + * @param args The arguments to pass to the program. Defaults to no arguments. + * @param options Spawn options such as the working directory, the environment + * variables and the character encoding of the process output. + * + * @returns The command instance, ready to be spawned or executed. + * + * @since 2.0.0 */ static sidecar( program: string, @@ -472,6 +772,15 @@ class Command extends EventEmitter { /** * Executes the command as a child process, returning a handle to it. * + * @example + * ```typescript + * import { Command } from '@tauri-apps/plugin-shell'; + * const command = Command.create('node'); + * command.stdout.on('data', (line) => console.log(line)); + * const child = await command.spawn(); + * console.log('pid:', child.pid); + * ``` + * * @returns A promise resolving to the child process handle. * * @since 2.0.0 @@ -562,7 +871,10 @@ interface TerminatedPayload { signal: number | null } -/** Event payload type */ +/** + * The type of the data a child process writes to `stdout` and `stderr`: + * a `string`, or a `Uint8Array` when the `raw` encoding is configured. + */ type IOPayload = string | Uint8Array /** Events emitted by the child process. */ diff --git a/plugins/shell/guest-js/init.ts b/plugins/shell/guest-js/init.ts index 117ba778d..d0ed650d2 100644 --- a/plugins/shell/guest-js/init.ts +++ b/plugins/shell/guest-js/init.ts @@ -2,6 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Webview script injected by the shell plugin on every page load. + * + * It installs a `click` listener on `document.body` that intercepts clicks on + * `` elements whose `href` starts with `http://`, `https://`, + * `mailto:` or `tel:`, cancels the navigation and opens the link with the + * system's default application through the `plugin:shell|open` command instead. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' // open links with the API diff --git a/plugins/shell/src/error.rs b/plugins/shell/src/error.rs index 652421b8f..1e2edcf87 100644 --- a/plugins/shell/src/error.rs +++ b/plugins/shell/src/error.rs @@ -6,17 +6,27 @@ use std::path::PathBuf; use serde::{Serialize, Serializer}; +/// Errors returned by the shell plugin. #[derive(Debug, thiserror::Error)] pub enum Error { + /// Failed to invoke the mobile plugin implementation. + /// + /// Only available on mobile. #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// I/O error, usually raised when spawning a process or reading its output. #[error(transparent)] Io(#[from] std::io::Error), + /// The path of the current executable has no parent directory, + /// so the path of a sidecar program could not be resolved. #[error("current executable path has no parent")] CurrentExeHasNoParent, + /// The string does not match any of the known programs + /// accepted by the deprecated `open` API. #[error("unknown program {0}")] UnknownProgramName(String), + /// The command is not allowed by the configured shell scope. #[error(transparent)] Scope(#[from] crate::scope::Error), /// Sidecar not allowed by the configuration. @@ -25,6 +35,7 @@ pub enum Error { /// Program not allowed by the scope. #[error("program not allowed on the configured shell scope: {0}")] ProgramNotAllowed(PathBuf), + /// The `encoding` spawn option is neither `raw` nor a label of a known character encoding. #[error("unknown encoding {0}")] UnknownEncoding(String), /// JSON error. diff --git a/plugins/shell/src/lib.rs b/plugins/shell/src/lib.rs index c9503731a..2615b12f4 100644 --- a/plugins/shell/src/lib.rs +++ b/plugins/shell/src/lib.rs @@ -29,6 +29,7 @@ mod error; #[deprecated(since = "2.1.0", note = "Use tauri-plugin-opener instead.")] #[allow(deprecated)] pub mod open; +/// Types and helpers to spawn and interact with child processes. pub mod process; mod scope; mod scope_entry; @@ -45,6 +46,9 @@ tauri::ios_plugin_binding!(init_plugin_shell); type ChildStore = Arc>>; +/// Access to the shell APIs. +/// +/// Get an instance of this type with [`ShellExt::shell`]. pub struct Shell { #[allow(dead_code)] app: AppHandle, @@ -90,7 +94,21 @@ impl Shell { } } +/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], +/// [`tauri::Webview`] and [`tauri::Window`] to access the shell APIs. pub trait ShellExt { + /// Gets the shell APIs. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_shell::ShellExt; + /// + /// async fn run_echo(app: &tauri::AppHandle) { + /// let output = app.shell().command("echo").args(["hello"]).output().await.unwrap(); + /// println!("{}", String::from_utf8_lossy(&output.stdout)); + /// } + /// ``` fn shell(&self) -> &Shell; } @@ -100,6 +118,18 @@ impl> ShellExt for T { } } +/// Initializes the shell plugin. +/// +/// The plugin state can be accessed with [`ShellExt::shell`], +/// and all spawned child processes are killed when the application exits. +/// +/// # Examples +/// +/// ```no_run +/// fn setup(builder: tauri::Builder) -> tauri::Builder { +/// builder.plugin(tauri_plugin_shell::init()) +/// } +/// ``` pub fn init() -> TauriPlugin> { Builder::>::new("shell") .js_init_script(include_str!("init-iife.js").to_string()) diff --git a/plugins/single-instance/src/lib.rs b/plugins/single-instance/src/lib.rs index c41a0fa10..9a254a517 100644 --- a/plugins/single-instance/src/lib.rs +++ b/plugins/single-instance/src/lib.rs @@ -33,16 +33,39 @@ mod semver_compat; pub(crate) type SingleInstanceCallback = dyn FnMut(&AppHandle, Vec, String) + Send + Sync + 'static; +/// Initializes the plugin, calling `f` whenever a second instance of the app is started. +/// +/// This is a shortcut for [`Builder::new`] with [`Builder::callback`] set to `f`, then +/// [`Builder::build`]. Use [`Builder`] directly if you need to set a custom [`Builder::dbus_id`]. +/// +/// `f` is called with the app handle, the second instance's command line arguments +/// (as collected by [`std::env::args`], so the first element is the executable path) and its +/// current working directory. If the `deep-link` feature is enabled, the arguments are first +/// forwarded to [`tauri-plugin-deep-link`](https://crates.io/crates/tauri-plugin-deep-link) +/// before `f` runs. +/// +/// The second instance never reaches [`tauri::Builder::run`]: it hands its arguments and working +/// directory off to the first instance and exits immediately. pub fn init, Vec, String) + Send + Sync + 'static>( f: F, ) -> TauriPlugin { Builder::new().callback(f).build() } +/// Releases the resources this plugin uses to detect other instances (the named mutex on +/// Windows, the D-Bus name on Linux or the Unix socket on macOS). +/// +/// The plugin calls this automatically on [`tauri::RunEvent::Exit`], so you normally don't need +/// to call it yourself. Call it manually before terminating the process through means that skip +/// that event, such as [`std::process::exit`], so a future instance of the app isn't mistaken +/// for a still-running one. pub fn destroy>(manager: &M) { platform_impl::destroy(manager) } +/// Builds the single-instance plugin. +/// +/// Created with [`Builder::new`] and consumed by [`Builder::build`]. pub struct Builder { callback: Box>, dbus_id: Option, @@ -63,6 +86,10 @@ impl Default for Builder { } impl Builder { + /// Creates a new builder with a no-op callback (or, when the `deep-link` feature is enabled, + /// a callback that only forwards the arguments to the deep-link plugin) and no custom D-Bus + /// ID. Use [`Builder::callback`] and [`Builder::dbus_id`] to configure it, then + /// [`Builder::build`] to create the plugin. pub fn new() -> Self { Default::default() } @@ -93,6 +120,10 @@ impl Builder { self } + /// Builds the single-instance [`TauriPlugin`]. + /// + /// Register it first among your app's plugins, since the plugins run in the order they were + /// added and a second instance must be detected before the rest of your setup runs. pub fn build(self) -> TauriPlugin { platform_impl::init( self.callback, diff --git a/plugins/sql/guest-js/index.ts b/plugins/sql/guest-js/index.ts index 11d39e70b..19e40afac 100644 --- a/plugins/sql/guest-js/index.ts +++ b/plugins/sql/guest-js/index.ts @@ -2,8 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Interface with SQL databases through [sqlx](https://github.com/launchbadge/sqlx). + * Which database engines can be used depends on the drivers enabled on the Rust + * side of the plugin: SQLite, MySQL and PostgreSQL. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' +/** + * The outcome of a statement run through {@link Database.execute}. + */ export interface QueryResult { /** The number of rows affected by the query. */ rowsAffected: number @@ -23,9 +34,30 @@ export interface QueryResult { * * The `Database` class serves as the primary interface for * communicating with the rust side of the sql plugin. + * + * @since 2.0.0 */ export default class Database { + /** + * The connection string identifying the database on the Rust side, + * for instance `sqlite:test.db`, `mysql://user:pass@host/database` + * or `postgres://user:pass@host/database`. + */ path: string + + /** + * Creates a `Database` instance for the given connection string without + * opening a connection to it. Use {@link Database.load} to connect to the + * database, or {@link Database.get} for a database that is already loaded. + * + * @param path The database connection string, such as `sqlite:test.db`. + * + * @example + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = new Database('sqlite:test.db') + * ``` + */ constructor(path: string) { this.path = path } @@ -41,9 +73,13 @@ export default class Database { * The path is relative to `tauri::path::BaseDirectory::App` and must start with `sqlite:`. * * @example - * ```ts - * const db = await Database.load("sqlite:test.db"); + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') * ``` + * + * @param path The database connection string, such as `sqlite:test.db`. The database is created if it does not exist yet, and any migration registered for it on the Rust side is run. + * @returns A promise resolving to a `Database` instance connected to the given database. */ static async load(path: string): Promise { const _path = await invoke('plugin:sql|load', { @@ -65,9 +101,13 @@ export default class Database { * The path is relative to `tauri::path::BaseDirectory::App` and must start with `sqlite:`. * * @example - * ```ts - * const db = Database.get("sqlite:test.db"); + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = Database.get('sqlite:test.db') * ``` + * + * @param path The database connection string, such as `sqlite:test.db`. + * @returns A `Database` instance bound to the given connection string. */ static get(path: string): Database { return new Database(path) @@ -79,7 +119,10 @@ export default class Database { * Passes a SQL expression to the database for execution. * * @example - * ```ts + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * * // for sqlite & postgres * // INSERT example * const result = await db.execute( @@ -104,6 +147,10 @@ export default class Database { * [ todos.title, todos.status, todos.id ] * ); * ``` + * + * @param query The SQL statement to run, using `$1`, `$2`, ... placeholders on SQLite and PostgreSQL and `?` placeholders on MySQL. + * @param bindValues The values bound to the query placeholders, in the order they appear in the statement. Defaults to no values. + * @returns A promise resolving to the number of rows affected by the statement and the last inserted id. */ async execute(query: string, bindValues?: unknown[]): Promise { const [rowsAffected, lastInsertId] = await invoke<[number, number]>( @@ -126,7 +173,10 @@ export default class Database { * Passes in a SELECT query to the database for execution. * * @example - * ```ts + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') + * * // for sqlite & postgres * const result = await db.select( * "SELECT * from todos WHERE id = $1", [ id ] @@ -137,6 +187,10 @@ export default class Database { * "SELECT * from todos WHERE id = ?", [ id ] * ); * ``` + * + * @param query The SQL query to run, using `$1`, `$2`, ... placeholders on SQLite and PostgreSQL and `?` placeholders on MySQL. + * @param bindValues The values bound to the query placeholders, in the order they appear in the query. Defaults to no values. + * @returns A promise resolving to the selected rows, each row being an object keyed by column name. */ async select(query: string, bindValues?: unknown[]): Promise { const result = await invoke('plugin:sql|select', { @@ -154,10 +208,14 @@ export default class Database { * Closes the database connection pool. * * @example - * ```ts + * ```typescript + * import Database from '@tauri-apps/plugin-sql' + * const db = await Database.load('sqlite:test.db') * const success = await db.close() * ``` + * * @param db - Optionally state the name of a database if you are managing more than one. Otherwise, all database pools will be in scope. + * @returns A promise resolving to `true` once the matching connection pools have been closed. */ async close(db?: string): Promise { const success = await invoke('plugin:sql|close', { diff --git a/plugins/sql/src/error.rs b/plugins/sql/src/error.rs index 5ac845b8a..0f4f68198 100644 --- a/plugins/sql/src/error.rs +++ b/plugins/sql/src/error.rs @@ -4,16 +4,30 @@ use serde::{Serialize, Serializer}; +/// Errors that can happen while connecting to a database, running migrations +/// or executing a query. +/// +/// Serializes to its [`std::fmt::Display`] representation, which is what the +/// frontend receives when a command fails. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An error reported by [`sqlx`], such as a failed connection or a query the database rejected. #[error(transparent)] Sql(#[from] sqlx::Error), + /// A migration registered with [`crate::Builder::add_migrations`] could not be resolved or applied. #[error(transparent)] Migration(#[from] sqlx::migrate::MigrateError), + /// The connection string is missing its `scheme:` prefix, or the scheme does not + /// match any of the enabled database drivers. Contains the offending connection string. #[error("invalid connection url: {0}")] InvalidDbUrl(String), + /// The requested database has not been connected to with the `load` command + /// and is not listed in the plugin's `preload` configuration. + /// Contains the connection string of the database. #[error("database {0} not loaded")] DatabaseNotLoaded(String), + /// A value selected from the database has a SQL type that cannot be converted + /// to JSON. Contains the name of that SQL type. #[error("unsupported datatype: {0}")] UnsupportedDatatype(String), } diff --git a/plugins/sql/src/lib.rs b/plugins/sql/src/lib.rs index e9df558a3..dca4928c5 100644 --- a/plugins/sql/src/lib.rs +++ b/plugins/sql/src/lib.rs @@ -40,6 +40,11 @@ use tokio::sync::{Mutex, RwLock}; use std::collections::HashMap; +/// The connection pools the plugin currently holds, keyed by the connection +/// string the database was loaded with. +/// +/// It is managed as Tauri state, so Rust code can reach the pools with +/// [`tauri::Manager::state`] and run its own queries. #[derive(Default)] pub struct DbInstances(pub RwLock>); @@ -58,15 +63,22 @@ pub(crate) enum LastInsertId { struct Migrations(Mutex>); +/// The `plugins > sql` section of the Tauri configuration file. #[derive(Default, Clone, Deserialize)] pub struct PluginConfig { + /// Connection strings of the databases to connect to when the application + /// starts. Empty by default. #[serde(default)] preload: Vec, } +/// The direction of a [`Migration`]. #[derive(Debug)] pub enum MigrationKind { + /// Moves the schema forward. Only migrations of this kind are executed by the plugin. Up, + /// Reverts an [`Up`](Self::Up) migration. Migrations of this kind are currently + /// never executed by the plugin. Down, } @@ -80,11 +92,23 @@ impl From for MigrationType { } /// A migration definition. +/// +/// Migrations are attached to a database with [`Builder::add_migrations`] and run +/// the first time that database is connected to - on startup for the databases +/// listed in the `preload` configuration, otherwise when the frontend loads it. +/// Only [`MigrationKind::Up`] migrations are executed, in ascending +/// [`version`](Self::version) order, and sqlx keeps track of the versions that +/// already ran so each one is applied at most once per database. #[derive(Debug)] pub struct Migration { + /// The version of this migration. Determines the order in which migrations + /// run and identifies the migration in the database. pub version: i64, + /// A human readable description of what the migration does. pub description: &'static str, + /// The SQL executed when the migration runs. pub sql: &'static str, + /// Whether this migration moves the schema forward or reverts it. pub kind: MigrationKind, } @@ -127,6 +151,11 @@ pub struct Builder { } impl Builder { + /// Creates a new builder with no migrations registered. + /// + /// Prints a message to stderr when none of the `sqlite`, `mysql` and + /// `postgres` Cargo features is enabled, since no database can be + /// connected to in that case. pub fn new() -> Self { #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgres")))] eprintln!("No sql driver enabled. Please set at least one of the \"sqlite\", \"mysql\", \"postgres\" feature flags."); @@ -143,6 +172,33 @@ impl Builder { self } + /// Builds the SQL plugin. + /// + /// On setup the plugin connects to every database listed in the `preload` + /// array of its [configuration](PluginConfig), running the migrations + /// registered for them, and it closes all connection pools when the + /// application exits. + /// + /// # Examples + /// + /// ``` + /// use tauri_plugin_sql::{Builder, Migration, MigrationKind}; + /// + /// fn sql_plugin( + /// ) -> tauri::plugin::TauriPlugin> { + /// Builder::new() + /// .add_migrations( + /// "sqlite:mydatabase.db", + /// vec![Migration { + /// version: 1, + /// description: "create todos table", + /// sql: "CREATE TABLE todos (id INTEGER PRIMARY KEY, title TEXT);", + /// kind: MigrationKind::Up, + /// }], + /// ) + /// .build() + /// } + /// ``` pub fn build(mut self) -> TauriPlugin> { PluginBuilder::>::new("sql") .invoke_handler(tauri::generate_handler![ diff --git a/plugins/sql/src/wrapper.rs b/plugins/sql/src/wrapper.rs index d47b2d1cb..f07a95e51 100644 --- a/plugins/sql/src/wrapper.rs +++ b/plugins/sql/src/wrapper.rs @@ -22,13 +22,23 @@ use sqlx::Sqlite; use crate::LastInsertId; +/// A connection pool for one of the supported database drivers. +/// +/// The variant is picked from the scheme of the connection string +/// (`sqlite:`, `mysql:` or `postgres:`) and only the variants whose Cargo +/// feature is enabled exist. pub enum DbPool { + /// A SQLite connection pool. Only available with the `sqlite` Cargo feature. #[cfg(feature = "sqlite")] Sqlite(Pool), + /// A MySQL connection pool. Only available with the `mysql` Cargo feature. #[cfg(feature = "mysql")] MySql(Pool), + /// A PostgreSQL connection pool. Only available with the `postgres` Cargo feature. #[cfg(feature = "postgres")] Postgres(Pool), + /// Placeholder used when none of the `sqlite`, `mysql` and `postgres` Cargo + /// features is enabled. Connecting always fails and every other operation is a no-op. #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgres")))] None, } diff --git a/plugins/store/guest-js/index.ts b/plugins/store/guest-js/index.ts index ec8f11657..fdec3e8db 100644 --- a/plugins/store/guest-js/index.ts +++ b/plugins/store/guest-js/index.ts @@ -2,6 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Simple, persistent key-value store. + * + * A store is persisted to a file inside the application data directory and is shared with the + * Rust side of the application, which can read and write the same store through its own API. + * + * @module + */ + import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { invoke, Resource } from '@tauri-apps/api/core' @@ -47,14 +56,20 @@ export type StoreOptions = { /** * Create a new Store or load the existing store with the path. * + * If the file at the given path does not exist yet, the store is created in memory with the + * configured defaults and the file is only written on the first save. + * * @example * ```typescript - * import { Store } from '@tauri-apps/api/store'; - * const store = await Store.load('store.json'); + * import { load } from '@tauri-apps/plugin-store'; + * const store = await load('store.json'); * ``` * * @param path Path to save the store in `app_data_dir` * @param options Store configuration options + * @returns A promise resolving to the loaded store. + * + * @since 2.1.0 */ export async function load( path: string, @@ -73,11 +88,14 @@ export async function load( * * @example * ```typescript - * import { getStore } from '@tauri-apps/api/store'; + * import { getStore } from '@tauri-apps/plugin-store'; * const store = await getStore('store.json'); * ``` * * @param path Path of the store. + * @returns A promise resolving to the store instance, or `null` if it is not loaded. + * + * @since 2.1.0 */ export async function getStore(path: string): Promise { return await Store.get(path) @@ -85,6 +103,11 @@ export async function getStore(path: string): Promise { /** * A lazy loaded key-value store persisted by the backend layer. + * + * The underlying {@linkcode Store} is only created or loaded when one of the methods of this + * class is called for the first time, and every call afterwards reuses that same instance. + * + * @since 2.1.0 */ export class LazyStore implements IStore { private _store?: Promise @@ -97,9 +120,18 @@ export class LazyStore implements IStore { } /** + * Creates a handle to the store at the given path without loading it yet. + * * Note that the options are not applied if someone else already created the store + * * @param path Path to save the store in `app_data_dir` * @param options Store configuration options + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * ``` */ constructor( private readonly path: string, @@ -108,59 +140,252 @@ export class LazyStore implements IStore { /** * Init/load the store if it's not loaded already + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.init(); + * ``` */ async init(): Promise { await this.store } + /** + * Inserts a key-value pair into the store, loading it first if needed. + * + * Delegates to {@linkcode Store.set} on the underlying store. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.set('some-key', { value: 5 }); + * ``` + * + * @param key The key to insert the value at. + * @param value The value to store, which must be serializable to JSON. + */ async set(key: string, value: unknown): Promise { return (await this.store).set(key, value) } + /** + * Returns the value for the given `key` or `undefined` if the key does not exist. + * + * Delegates to {@linkcode Store.get} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const value = await store.get<{ value: number }>('some-key'); + * ``` + * + * @param key The key to read the value of. + * @returns A promise resolving to the stored value, or `undefined` if the key does not exist. + */ async get(key: string): Promise { return (await this.store).get(key) } + /** + * Returns `true` if the given `key` exists in the store. + * + * Delegates to {@linkcode Store.has} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const exists = await store.has('some-key'); + * ``` + * + * @param key The key to check. + * @returns A promise resolving to `true` if the key exists in the store. + */ async has(key: string): Promise { return (await this.store).has(key) } + /** + * Removes a key-value pair from the store. + * + * Delegates to {@linkcode Store.delete} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const removed = await store.delete('some-key'); + * ``` + * + * @param key The key to remove. + * @returns A promise resolving to `true` if the key existed and was removed. + */ async delete(key: string): Promise { return (await this.store).delete(key) } + /** + * Clears the store, removing all key-value pairs. + * + * Note: To clear the storage and reset it to its `default` value, use {@linkcode reset} instead. + * Delegates to {@linkcode Store.clear} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.clear(); + * ``` + */ async clear(): Promise { await (await this.store).clear() } + /** + * Resets the store to its `default` value. + * + * If no default value has been set, this method behaves identical to {@linkcode clear}. + * Delegates to {@linkcode Store.reset} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json', { defaults: { 'some-key': 0 } }); + * await store.reset(); + * ``` + */ async reset(): Promise { await (await this.store).reset() } + /** + * Returns a list of all keys in the store. + * + * Delegates to {@linkcode Store.keys} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const keys = await store.keys(); + * ``` + * + * @returns A promise resolving to the list of keys, in arbitrary order. + */ async keys(): Promise { return (await this.store).keys() } + /** + * Returns a list of all values in the store. + * + * Delegates to {@linkcode Store.values} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const values = await store.values(); + * ``` + * + * @returns A promise resolving to the list of values, in arbitrary order. + */ async values(): Promise { return (await this.store).values() } + /** + * Returns a list of all entries in the store. + * + * Delegates to {@linkcode Store.entries} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const entries = await store.entries(); + * ``` + * + * @returns A promise resolving to the list of key-value pairs, in arbitrary order. + */ async entries(): Promise> { return (await this.store).entries() } + /** + * Returns the number of key-value pairs in the store. + * + * Delegates to {@linkcode Store.length} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const length = await store.length(); + * ``` + * + * @returns A promise resolving to the number of key-value pairs in the store. + */ async length(): Promise { return (await this.store).length() } + /** + * Attempts to load the on-disk state at the store's `path` into memory. + * + * Delegates to {@linkcode Store.reload} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.reload({ ignoreDefaults: true }); + * ``` + * + * @param options Options to change how the on-disk state is merged into the store. + */ async reload(options?: ReloadOptions): Promise { await (await this.store).reload(options) } + /** + * Saves the store to disk at the store's `path`. + * + * Delegates to {@linkcode Store.save} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.save(); + * ``` + */ async save(): Promise { await (await this.store).save() } + /** + * Listen to changes on a store key. + * + * Delegates to {@linkcode Store.onKeyChange} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const unlisten = await store.onKeyChange<{ value: number }>('some-key', (value) => { + * console.log(value); + * }); + * ``` + * + * @param key The key to watch for changes. + * @param cb Callback invoked with the new value, or `undefined` when the key was removed. + * @returns A promise resolving to a function to unlisten to the event. + */ async onKeyChange( key: string, cb: (value: T | undefined) => void @@ -168,12 +393,43 @@ export class LazyStore implements IStore { return (await this.store).onKeyChange(key, cb) } + /** + * Listen to changes on the store. + * + * Delegates to {@linkcode Store.onChange} on the underlying store, loading it first if needed. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * const unlisten = await store.onChange<{ value: number }>((key, value) => { + * console.log(key, value); + * }); + * ``` + * + * @param cb Callback invoked with the changed key and its new value, which is `undefined` when the key was removed. + * @returns A promise resolving to a function to unlisten to the event. + */ async onChange( cb: (key: string, value: T | undefined) => void ): Promise { return (await this.store).onChange(cb) } + /** + * Close the store and cleans up this resource from memory. + * **You should not call any method on this object anymore and should drop any reference to it.** + * + * Delegates to {@linkcode Store.close} on the underlying store. + * If the store was never loaded, this method does nothing. + * + * @example + * ```typescript + * import { LazyStore } from '@tauri-apps/plugin-store'; + * const store = new LazyStore('store.json'); + * await store.close(); + * ``` + */ async close(): Promise { if (this._store) { await (await this._store).close() @@ -183,6 +439,12 @@ export class LazyStore implements IStore { /** * A key-value store persisted by the backend layer. + * + * The values are kept in memory and written to the store's file on {@linkcode Store.save}, + * and automatically after every modification unless auto save is disabled with + * {@linkcode StoreOptions.autoSave}. + * + * @since 2.0.0 */ export class Store extends Resource implements IStore { private constructor(rid: number) { @@ -192,14 +454,18 @@ export class Store extends Resource implements IStore { /** * Create a new Store or load the existing store with the path. * + * If the file at the given path does not exist yet, the store is created in memory with the + * configured defaults and the file is only written on the first save. + * * @example * ```typescript - * import { Store } from '@tauri-apps/api/store'; + * import { Store } from '@tauri-apps/plugin-store'; * const store = await Store.load('store.json'); * ``` * * @param path Path to save the store in `app_data_dir` * @param options Store configuration options + * @returns A promise resolving to the loaded store. */ static async load(path: string, options?: StoreOptions): Promise { const rid = await invoke('plugin:store|load', { @@ -219,7 +485,7 @@ export class Store extends Resource implements IStore { * * @example * ```typescript - * import { Store } from '@tauri-apps/api/store'; + * import { Store } from '@tauri-apps/plugin-store'; * let store = await Store.get('store.json'); * if (!store) { * store = await Store.load('store.json'); @@ -227,6 +493,7 @@ export class Store extends Resource implements IStore { * ``` * * @param path Path of the store. + * @returns A promise resolving to the store instance, or `null` if it is not loaded. */ static async get(path: string): Promise { return await invoke('plugin:store|get_store', { path }).then( @@ -234,6 +501,22 @@ export class Store extends Resource implements IStore { ) } + /** + * Inserts a key-value pair into the store. + * + * A change event is emitted for the key and, unless auto save is disabled, the store is + * scheduled to be written to disk. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * await store.set('some-key', { value: 5 }); + * ``` + * + * @param key The key to insert the value at. + * @param value The value to store, which must be serializable to JSON. + */ async set(key: string, value: unknown): Promise { await invoke('plugin:store|set', { rid: this.rid, @@ -242,6 +525,19 @@ export class Store extends Resource implements IStore { }) } + /** + * Returns the value for the given `key` or `undefined` if the key does not exist. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const value = await store.get<{ value: number }>('some-key'); + * ``` + * + * @param key The key to read the value of. + * @returns A promise resolving to the stored value, or `undefined` if the key does not exist. + */ async get(key: string): Promise { const [value, exists] = await invoke<[T, boolean]>('plugin:store|get', { rid: this.rid, @@ -250,6 +546,19 @@ export class Store extends Resource implements IStore { return exists ? value : undefined } + /** + * Returns `true` if the given `key` exists in the store. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const exists = await store.has('some-key'); + * ``` + * + * @param key The key to check. + * @returns A promise resolving to `true` if the key exists in the store. + */ async has(key: string): Promise { return await invoke('plugin:store|has', { rid: this.rid, @@ -257,6 +566,22 @@ export class Store extends Resource implements IStore { }) } + /** + * Removes a key-value pair from the store. + * + * A change event is emitted when the key existed and, unless auto save is disabled, the store + * is scheduled to be written to disk. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const removed = await store.delete('some-key'); + * ``` + * + * @param key The key to remove. + * @returns A promise resolving to `true` if the key existed and was removed. + */ async delete(key: string): Promise { return await invoke('plugin:store|delete', { rid: this.rid, @@ -264,38 +589,165 @@ export class Store extends Resource implements IStore { }) } + /** + * Clears the store, removing all key-value pairs. + * + * Note: To clear the storage and reset it to its `default` value, use {@linkcode reset} instead. + * A change event is emitted for every removed key. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * await store.clear(); + * ``` + */ async clear(): Promise { await invoke('plugin:store|clear', { rid: this.rid }) } + /** + * Resets the store to its `default` value. + * + * If no default value has been set, this method behaves identical to {@linkcode clear}. + * A change event is emitted for every key whose value changed. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json', { defaults: { 'some-key': 0 } }); + * await store.reset(); + * ``` + */ async reset(): Promise { await invoke('plugin:store|reset', { rid: this.rid }) } + /** + * Returns a list of all keys in the store. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const keys = await store.keys(); + * ``` + * + * @returns A promise resolving to the list of keys, in arbitrary order. + */ async keys(): Promise { return await invoke('plugin:store|keys', { rid: this.rid }) } + /** + * Returns a list of all values in the store. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const values = await store.values(); + * ``` + * + * @returns A promise resolving to the list of values, in arbitrary order. + */ async values(): Promise { return await invoke('plugin:store|values', { rid: this.rid }) } + /** + * Returns a list of all entries in the store. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const entries = await store.entries(); + * ``` + * + * @returns A promise resolving to the list of key-value pairs, in arbitrary order. + */ async entries(): Promise> { return await invoke('plugin:store|entries', { rid: this.rid }) } + /** + * Returns the number of key-value pairs in the store. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const length = await store.length(); + * ``` + * + * @returns A promise resolving to the number of key-value pairs in the store. + */ async length(): Promise { return await invoke('plugin:store|length', { rid: this.rid }) } + /** + * Attempts to load the on-disk state at the store's `path` into memory. + * + * This method is useful if the on-disk state was edited by the user and you want to synchronize the changes. + * + * Note: + * - This method loads the data and merges it with the current store, + * this behavior will be changed to resetting to default first and then merging with the on-disk state in v3, + * to fully match the store with the on-disk state, set {@linkcode ReloadOptions | ignoreDefaults} to `true` + * - This method does not emit change events. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * await store.reload({ ignoreDefaults: true }); + * ``` + * + * @param options Options to change how the on-disk state is merged into the store. + */ async reload(options?: ReloadOptions): Promise { await invoke('plugin:store|reload', { rid: this.rid, ...options }) } + /** + * Saves the store to disk at the store's `path`. + * + * Any pending auto save is cancelled, so the store is written exactly once by this call. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json', { autoSave: false }); + * await store.set('some-key', { value: 5 }); + * await store.save(); + * ``` + */ async save(): Promise { await invoke('plugin:store|save', { rid: this.rid }) } + /** + * Listen to changes on a store key. + * + * The callback is only invoked for changes made to this store instance. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const unlisten = await store.onKeyChange<{ value: number }>('some-key', (value) => { + * console.log(value); + * }); + * ``` + * + * @param key The key to watch for changes. + * @param cb Callback invoked with the new value, or `undefined` when the key was removed. + * @returns A promise resolving to a function to unlisten to the event. + * + * @since 2.0.0 + */ async onKeyChange( key: string, cb: (value: T | undefined) => void @@ -307,6 +759,25 @@ export class Store extends Resource implements IStore { }) } + /** + * Listen to changes on the store. + * + * The callback is only invoked for changes made to this store instance. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-store'; + * const store = await Store.load('store.json'); + * const unlisten = await store.onChange<{ value: number }>((key, value) => { + * console.log(key, value); + * }); + * ``` + * + * @param cb Callback invoked with the changed key and its new value, which is `undefined` when the key was removed. + * @returns A promise resolving to a function to unlisten to the event. + * + * @since 2.0.0 + */ async onChange( cb: (key: string, value: T | undefined) => void ): Promise { @@ -452,7 +923,7 @@ interface IStore { } /** - * Options to {@linkcode IStore.reload} a {@linkcode IStore} + * Options to change how a store is reloaded from its on-disk state. */ export type ReloadOptions = { /** diff --git a/plugins/store/src/error.rs b/plugins/store/src/error.rs index ef5ee5938..29abf9dbc 100644 --- a/plugins/store/src/error.rs +++ b/plugins/store/src/error.rs @@ -4,14 +4,17 @@ use serde::{Serialize, Serializer}; +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; /// The error types. #[derive(thiserror::Error, Debug)] #[non_exhaustive] pub enum Error { + /// The store contents could not be serialized by the configured [`SerializeFn`](crate::SerializeFn). #[error("Failed to serialize store. {0}")] Serialize(Box), + /// The store contents could not be deserialized by the configured [`DeserializeFn`](crate::DeserializeFn). #[error("Failed to deserialize store. {0}")] Deserialize(Box), /// JSON error. diff --git a/plugins/store/src/lib.rs b/plugins/store/src/lib.rs index 458bc4b02..4a49dda01 100644 --- a/plugins/store/src/lib.rs +++ b/plugins/store/src/lib.rs @@ -240,6 +240,11 @@ async fn save(app: AppHandle, rid: ResourceId) -> Result<()> { store.save() } +/// Extension trait to access the store APIs on a [`Manager`] such as `App`, `AppHandle`, +/// `WebviewWindow` or `Window`. +/// +/// The plugin must be registered with [`Builder::build`] for these methods to work, +/// as they rely on the state it manages. pub trait StoreExt { /// Create a store or load an existing store with default settings at the given path. /// @@ -336,6 +341,17 @@ fn default_deserialize( serde_json::from_slice(bytes).map_err(Into::into) } +/// Builder for the store plugin. +/// +/// It is used to register custom serialize and deserialize functions the frontend can select by +/// name when loading a store, and to change the functions used by default (pretty printed JSON). +/// +/// # Examples +/// +/// ``` +/// tauri::Builder::default() +/// .plugin(tauri_plugin_store::Builder::default().build()); +/// ``` pub struct Builder { serialize_fns: HashMap, deserialize_fns: HashMap, @@ -355,6 +371,10 @@ impl Default for Builder { } impl Builder { + /// Creates a new builder using the default serialize and deserialize functions, + /// which read and write pretty printed JSON. + /// + /// This is the same as [`Builder::default`]. pub fn new() -> Self { Self::default() } diff --git a/plugins/store/src/store.rs b/plugins/store/src/store.rs index b597a4369..b13a34ab4 100644 --- a/plugins/store/src/store.rs +++ b/plugins/store/src/store.rs @@ -18,11 +18,25 @@ use tokio::{ time::sleep, }; +/// Function used to serialize the store cache to the bytes written to the store file. +/// +/// The default implementation writes pretty printed JSON. pub type SerializeFn = fn(&HashMap) -> Result, Box>; +/// Function used to deserialize the bytes read from the store file into the store cache. +/// +/// The default implementation parses JSON. pub type DeserializeFn = fn(&[u8]) -> Result, Box>; +/// Resolves the path of a store file, relative to the app data directory +/// ([`BaseDirectory::AppData`]). +/// +/// This is the path the [`Store`] created with the given `path` reads from and writes to. +/// +/// # Errors +/// +/// Returns an error if the app data directory cannot be resolved. pub fn resolve_store_path( app: &AppHandle, path: impl AsRef, @@ -428,6 +442,15 @@ impl std::fmt::Debug for StoreInner { } } +/// A key-value store, persisted to a file resolved with [`resolve_store_path`]. +/// +/// The values are kept in memory and written to disk on [`Store::save`], and also automatically +/// after each modification unless auto save has been disabled with +/// [`StoreBuilder::disable_auto_save`]. Any pending auto save is applied when the store is dropped. +/// +/// Create or load one with [`StoreExt::store`](crate::StoreExt::store) or [`StoreBuilder`]. +/// It is a [`Resource`], so it is also reachable from the frontend by its [`ResourceId`]; +/// closing that resource unregisters the store, meaning the next load creates a new instance. pub struct Store { auto_save: Option, auto_save_debounce_sender: Arc>>>, diff --git a/plugins/stronghold/guest-js/index.ts b/plugins/stronghold/guest-js/index.ts index c1945a210..ca751cbb1 100644 --- a/plugins/stronghold/guest-js/index.ts +++ b/plugins/stronghold/guest-js/index.ts @@ -2,72 +2,147 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Store secrets and keys using the [IOTA Stronghold](https://github.com/iotaledger/stronghold.rs) + * encrypted database and secure runtime. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' +/** + * The name of a Stronghold client, either as a UTF-8 string or as its raw byte representation. + */ export type ClientPath = | string | Iterable | ArrayLike | ArrayBuffer +/** + * The path of a vault inside a client, either as a UTF-8 string or as its raw byte representation. + */ export type VaultPath = | string | Iterable | ArrayLike | ArrayBuffer +/** + * The path of a record inside a vault, either as a UTF-8 string or as its raw byte representation. + */ export type RecordPath = | string | Iterable | ArrayLike | ArrayBuffer +/** + * The key of a record in a client store. Note that the store commands read the + * key as a string on the Rust side, so string keys are the safe choice. + */ export type StoreKey = | string | Iterable | ArrayLike | ArrayBuffer +/** + * The limits applied to the connections of a Stronghold peer-to-peer network. + * See {@link NetworkConfig} for a note on how these definitions are used. + */ export interface ConnectionLimits { + /** The maximum number of incoming connections that can be pending at the same time. */ maxPendingIncoming?: number + /** The maximum number of outgoing connections that can be pending at the same time. */ maxPendingOutgoing?: number + /** The maximum number of established incoming connections. */ maxEstablishedIncoming?: number + /** The maximum number of established outgoing connections. */ maxEstablishedOutgoing?: number + /** The maximum number of established connections per peer. */ maxEstablishedPerPeer?: number + /** The maximum number of established connections, incoming and outgoing combined. */ maxEstablishedTotal?: number } +/** + * The addresses on which a remote peer can be reached. + * See {@link NetworkConfig} for a note on how these definitions are used. + */ export interface PeerAddress { + /** The addresses that are known for the peer, in the multiaddr format. */ known: string[] // multiaddr + /** Whether the peer may be reached through a relay when its known addresses cannot be used. */ use_relay_fallback: boolean } +/** + * The address book of the peer-to-peer network. + * See {@link NetworkConfig} for a note on how these definitions are used. + */ export interface AddressInfo { + /** The known addresses of each peer, keyed by the peer identifier. */ peers: Map + /** The identifiers of the peers that can be used as relays. */ relays: string[] // peers } +/** + * The operations a remote peer is allowed to perform on a client. + * See {@link NetworkConfig} for a note on how these definitions are used. + */ export interface ClientAccess { + /** The default permission to use the secrets stored in a vault. */ useVaultDefault?: boolean + /** Per-vault overrides of {@link ClientAccess.useVaultDefault}. */ useVaultExceptions?: Map + /** The default permission to write secrets to a vault. */ writeVaultDefault?: boolean + /** Per-vault overrides of {@link ClientAccess.writeVaultDefault}. */ writeVaultExceptions?: Map + /** The default permission to clone the secrets of a vault. */ cloneVaultDefault?: boolean + /** Per-vault overrides of {@link ClientAccess.cloneVaultDefault}. */ cloneVaultExceptions?: Map + /** Whether the store of the client can be read. */ readStore?: boolean + /** Whether the store of the client can be written to. */ writeStore?: boolean } +/** + * The access a remote peer is granted on the clients of a snapshot. + * See {@link NetworkConfig} for a note on how these definitions are used. + */ export interface Permissions { + /** The access granted when no exception matches. */ default?: ClientAccess + /** The access granted for specific paths, overriding {@link Permissions.default}. */ exceptions?: Map } +/** + * The configuration of the Stronghold peer-to-peer network. + * + * These definitions mirror the networking options of IOTA Stronghold. The plugin + * does not currently expose a command that consumes them, so they are only useful + * as type definitions. + */ export interface NetworkConfig { + /** The maximum time to wait for the response to an outbound request. */ requestTimeout?: Duration + /** The maximum time to wait when establishing a connection to a peer. */ connectionTimeout?: Duration + /** The limits applied to pending and established connections. */ connectionsLimit?: ConnectionLimits + /** Whether peers on the local network are discovered through mDNS. */ enableMdns?: boolean + /** Whether connections relayed by another peer are enabled. */ enableRelay?: boolean + /** The addresses of the known peers and of the available relays. */ addresses?: AddressInfo + /** The permissions granted to specific peers, keyed by the peer identifier. */ peerPermissions?: Map + /** The permissions granted to the peers that have no entry in {@link NetworkConfig.peerPermissions}. */ permissionsDefault?: Permissions } @@ -79,15 +154,49 @@ export interface Duration { nanos: number } +/** + * A pointer to a record inside a vault, either addressed by its record path + * ({@link Location.generic}) or by a counter ({@link Location.counter}). + * + * @since 2.0.0 + */ export class Location { + /** The location kind, either `Generic` or `Counter`. */ type: string + /** The location data, holding the vault path and the record path or counter. */ payload: Record + /** + * Creates a location of the given kind. Prefer the {@link Location.generic} + * and {@link Location.counter} helpers, which fill the payload for you. + * + * @example + * ```typescript + * import { Location } from '@tauri-apps/plugin-stronghold'; + * const location = new Location('Generic', { vault: 'my-vault', record: 'my-record' }); + * ``` + * + * @param type The location kind, either `Generic` or `Counter`. + * @param payload The data identifying the record inside the vault. + */ constructor(type: string, payload: Record) { this.type = type this.payload = payload } + /** + * Creates a location addressing a record of a vault by its record path. + * + * @example + * ```typescript + * import { Location } from '@tauri-apps/plugin-stronghold'; + * const location = Location.generic('my-vault', 'my-record'); + * ``` + * + * @param vault The path of the vault holding the record. + * @param record The path of the record inside the vault. + * @returns The location of the record. + */ static generic(vault: VaultPath, record: RecordPath): Location { return new Location('Generic', { vault, @@ -95,6 +204,19 @@ export class Location { }) } + /** + * Creates a location addressing a record of a vault by a counter. + * + * @example + * ```typescript + * import { Location } from '@tauri-apps/plugin-stronghold'; + * const location = Location.counter('my-vault', 0); + * ``` + * + * @param vault The path of the vault holding the record. + * @param counter The counter identifying the record inside the vault. + * @returns The location of the record. + */ static counter(vault: VaultPath, counter: number): Location { return new Location('Counter', { vault, @@ -114,8 +236,7 @@ class ProcedureExecutor { * Generate a SLIP10 seed for the given location. * @param outputLocation Location of the record where the seed will be stored. * @param sizeBytes The size in bytes of the SLIP10 seed. - * @param hint The record hint. - * @returns + * @returns A promise resolving to the bytes returned by the procedure. */ async generateSLIP10Seed( outputLocation: Location, @@ -134,13 +255,13 @@ class ProcedureExecutor { } /** - * Derive a SLIP10 private key using a seed or key. + * Derive a SLIP10 private key using a seed or key. The derivation is always + * performed on the Ed25519 curve. * @param chain The chain path. * @param source The source type, either 'Seed' or 'Key'. * @param sourceLocation The source location, must be the `outputLocation` of a previous call to `generateSLIP10Seed` or `deriveSLIP10`. * @param outputLocation Location of the record where the private key will be stored. - * @param hint The record hint. - * @returns + * @returns A promise resolving to the bytes returned by the procedure. */ async deriveSLIP10( chain: number[], @@ -169,8 +290,7 @@ class ProcedureExecutor { * @param mnemonic The mnemonic string. * @param outputLocation The location of the record where the BIP39 mnemonic will be stored. * @param passphrase The optional mnemonic passphrase. - * @param hint The record hint. - * @returns + * @returns A promise resolving to the bytes returned by the procedure. */ async recoverBIP39( mnemonic: string, @@ -191,11 +311,10 @@ class ProcedureExecutor { } /** - * Generate a BIP39 seed. + * Generate a BIP39 seed. The mnemonic is generated in English. * @param outputLocation The location of the record where the BIP39 seed will be stored. * @param passphrase The optional mnemonic passphrase. - * @param hint The record hint. - * @returns + * @returns A promise resolving to the bytes returned by the procedure. */ async generateBIP39( outputLocation: Location, @@ -258,39 +377,118 @@ class ProcedureExecutor { } } +/** + * A client of a stronghold snapshot, owning a set of vaults and a key-value store. + * Clients are obtained with {@link Stronghold.loadClient} and {@link Stronghold.createClient}. + * + * @since 2.0.0 + */ export class Client { + /** The path of the snapshot file this client belongs to. */ path: string + /** The name identifying this client inside the snapshot. */ name: ClientPath + /** + * Creates a client handle for a client that was already loaded or created. + * Prefer {@link Stronghold.loadClient} and {@link Stronghold.createClient}, + * which also register the client on the Rust side. + * + * @example + * ```typescript + * import { Client } from '@tauri-apps/plugin-stronghold'; + * const client = new Client('/path/to/snapshot.hold', 'my-client'); + * ``` + * + * @param path The path of the snapshot file the client belongs to. + * @param name The name identifying the client inside the snapshot. + */ constructor(path: string, name: ClientPath) { this.path = path this.name = name } /** - * Get a vault by name. - * @param name - * @param flags - * @returns + * Gets a handle to the vault with the given name. The vault is created on the + * Rust side when the first secret is written to it. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * const vault = client.getVault('my-vault'); + * ``` + * + * @param name The path of the vault. + * @returns The vault handle. */ getVault(name: VaultPath): Vault { return new Vault(this.path, this.name, name) } + /** + * Gets a handle to the key-value store of this client. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * const store = client.getStore(); + * ``` + * + * @returns The store handle. + */ getStore(): Store { return new Store(this.path, this.name) } } +/** + * The key-value store of a {@link Client}. Unlike a {@link Vault}, the values + * stored here can be read back directly. + * + * @since 2.0.0 + */ export class Store { + /** The path of the snapshot file this store belongs to. */ path: string + /** The name of the client owning this store. */ client: ClientPath + /** + * Creates a store handle for a client that was already loaded or created. + * Prefer {@link Client.getStore}. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/plugin-stronghold'; + * const store = new Store('/path/to/snapshot.hold', 'my-client'); + * ``` + * + * @param path The path of the snapshot file the store belongs to. + * @param client The name of the client owning the store. + */ constructor(path: string, client: ClientPath) { this.path = path this.client = client } + /** + * Reads the value of a record of this store. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * const value = await client.getStore().get('my-key'); + * ``` + * + * @param key The key of the record. + * @returns A promise resolving to the stored value, or `null` if the key does not exist. + */ async get(key: StoreKey): Promise { return await invoke('plugin:stronghold|get_store_record', { snapshotPath: this.path, @@ -299,6 +497,24 @@ export class Store { }).then((v) => v && Uint8Array.from(v)) } + /** + * Inserts a record in this store, replacing the previous value of the key. + * Note that the snapshot is only persisted when {@link Stronghold.save} is called. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * const data = Array.from(new TextEncoder().encode('Hello, World!')); + * await client.getStore().insert('my-key', data); + * await stronghold.save(); + * ``` + * + * @param key The key of the record. + * @param value The value of the record, as an array of bytes. + * @param lifetime The optional duration after which the record expires. + */ async insert( key: StoreKey, value: number[], @@ -313,6 +529,20 @@ export class Store { }) } + /** + * Deletes a record from this store. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * await client.getStore().remove('my-key'); + * ``` + * + * @param key The key of the record. + * @returns A promise resolving to the deleted value, or `null` if the key did not exist. + */ async remove(key: StoreKey): Promise { return await invoke( 'plugin:stronghold|remove_store_record', @@ -329,14 +559,31 @@ export class Store { * A key-value storage that allows create, update and delete operations. * It does not allow reading the data, so one of the procedures must be used to manipulate * the stored data, allowing secure storage of secrets. + * + * @since 2.0.0 */ export class Vault extends ProcedureExecutor { - /** The vault path. */ + /** The path of the snapshot file this vault belongs to. */ path: string + /** The name of the client owning this vault. */ client: ClientPath - /** The vault name. */ + /** The path identifying this vault inside the client. */ name: VaultPath + /** + * Creates a vault handle for a client that was already loaded or created. + * Prefer {@link Client.getVault}. + * + * @example + * ```typescript + * import { Vault } from '@tauri-apps/plugin-stronghold'; + * const vault = new Vault('/path/to/snapshot.hold', 'my-client', 'my-vault'); + * ``` + * + * @param path The path of the snapshot file the vault belongs to. + * @param client The name of the client owning the vault. + * @param name The path identifying the vault inside the client. + */ constructor(path: string, client: ClientPath, name: VaultPath) { super({ snapshotPath: path, @@ -349,11 +596,21 @@ export class Vault extends ProcedureExecutor { } /** - * Insert a record to this vault. - * @param location The record location. - * @param record The record data. - * @param recordHint The record hint. - * @returns + * Writes a secret to this vault. Note that the snapshot is only persisted + * when {@link Stronghold.save} is called. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * const secret = Array.from(new TextEncoder().encode('secret value')); + * await client.getVault('my-vault').insert('my-record', secret); + * await stronghold.save(); + * ``` + * + * @param recordPath The path of the record inside this vault. + * @param secret The secret to store, as an array of bytes. */ async insert(recordPath: RecordPath, secret: number[]): Promise { await invoke('plugin:stronghold|save_secret', { @@ -366,10 +623,20 @@ export class Vault extends ProcedureExecutor { } /** - * Remove a record from the vault. - * @param location The record location. - * @param gc Whether to additionally perform the gargage collection or not. - * @returns + * Deletes a secret from this vault. Only the record path of the given + * location is used, the vault is always this one. + * + * @example + * ```typescript + * import { Stronghold, Location } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * await client + * .getVault('my-vault') + * .remove(Location.generic('my-vault', 'my-record')); + * ``` + * + * @param location The location of the record to delete. */ async remove(location: Location): Promise { await invoke('plugin:stronghold|remove_secret', { @@ -383,15 +650,16 @@ export class Vault extends ProcedureExecutor { /** * A representation of an access to a stronghold. + * + * @since 2.0.0 */ export class Stronghold { + /** The path of the snapshot file backing this stronghold. */ path: string /** - * Initializes a stronghold. - * If the snapshot path located at `path` exists, the password must match. - * @param path - * @param password + * Creates a handle to the stronghold initialized for the given snapshot path. + * @param path The path of the snapshot file. */ private constructor(path: string) { this.path = path @@ -399,8 +667,17 @@ export class Stronghold { /** * Load the snapshot if it exists (password must match), or start a fresh stronghold instance otherwise. - * @param password - * @returns + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * import { appDataDir } from '@tauri-apps/api/path'; + * const stronghold = await Stronghold.load(`${await appDataDir()}/vault.hold`, 'password'); + * ``` + * + * @param path The path of the snapshot file. + * @param password The password used to encrypt and decrypt the snapshot. + * @returns A promise resolving to the stronghold instance. */ static async load(path: string, password: string): Promise { return await invoke('plugin:stronghold|initialize', { @@ -410,7 +687,14 @@ export class Stronghold { } /** - * Remove this instance from the cache. + * Saves the snapshot and removes this instance from the cache. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * await stronghold.unload(); + * ``` */ async unload(): Promise { await invoke('plugin:stronghold|destroy', { @@ -418,6 +702,20 @@ export class Stronghold { }) } + /** + * Loads an existing client from the snapshot. The promise rejects if the + * client does not exist in the snapshot or was already loaded. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.loadClient('my-client'); + * ``` + * + * @param client The name of the client. + * @returns A promise resolving to the loaded client. + */ async loadClient(client: ClientPath): Promise { return await invoke('plugin:stronghold|load_client', { snapshotPath: this.path, @@ -425,6 +723,24 @@ export class Stronghold { }).then(() => new Client(this.path, client)) } + /** + * Creates a new empty client on this stronghold. + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * let client; + * try { + * client = await stronghold.loadClient('my-client'); + * } catch { + * client = await stronghold.createClient('my-client'); + * } + * ``` + * + * @param client The name of the client. + * @returns A promise resolving to the created client. + */ async createClient(client: ClientPath): Promise { return await invoke('plugin:stronghold|create_client', { snapshotPath: this.path, @@ -434,7 +750,15 @@ export class Stronghold { /** * Persists the stronghold state to the snapshot. - * @returns + * + * @example + * ```typescript + * import { Stronghold } from '@tauri-apps/plugin-stronghold'; + * const stronghold = await Stronghold.load('/path/to/snapshot.hold', 'password'); + * const client = await stronghold.createClient('my-client'); + * await client.getStore().insert('my-key', [1, 2, 3]); + * await stronghold.save(); + * ``` */ async save(): Promise { await invoke('plugin:stronghold|save', { diff --git a/plugins/stronghold/src/kdf.rs b/plugins/stronghold/src/kdf.rs index 2389b1c41..f0d3aa8cb 100644 --- a/plugins/stronghold/src/kdf.rs +++ b/plugins/stronghold/src/kdf.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Key derivation used to turn the user password into the key that encrypts a snapshot. +//! +//! Only available when the **kdf** Cargo feature is enabled, which is the case by default. + use rand_chacha::ChaCha20Rng; use rand_core::{RngCore, SeedableRng}; use std::path::Path; @@ -10,12 +14,21 @@ use std::path::Path; /// This is a current limitation of Stronghold. const HASH_LENGTH: usize = 32; +/// Password hashing functions that can be used as the key derivation function of +/// [`Builder::new`](crate::Builder::new). pub struct KeyDerivation {} impl KeyDerivation { - /// Will create a key from [`password`] and a generated salt. - /// Salt will be generated to file [`salt_path`] or taken from it - /// if file already exists + /// Hashes `password` with Argon2 using the salt stored in `salt_path`, returning the + /// 32 bytes key used to encrypt a snapshot. + /// + /// The salt is read from `salt_path` when that file already exists, otherwise a new + /// random salt is generated and written to it. + /// + /// # Panics + /// + /// Panics when the salt file cannot be read or written, when its contents are not + /// 32 bytes long, or when hashing the password fails. pub fn argon2(password: &str, salt_path: &Path) -> Vec { let mut salt = [0u8; HASH_LENGTH]; create_or_get_salt(&mut salt, salt_path); diff --git a/plugins/stronghold/src/lib.rs b/plugins/stronghold/src/lib.rs index 020bcd21b..95ea71513 100644 --- a/plugins/stronghold/src/lib.rs +++ b/plugins/stronghold/src/lib.rs @@ -108,8 +108,13 @@ impl From for Slip10DeriveInput { } } +/// The type of a key pair handled by the plugin procedures. +/// +/// Deserialized from the strings `ed25519` and `x25519`, ignoring case. pub enum KeyType { + /// The Ed25519 signature scheme. Ed25519, + /// The X25519 key exchange scheme. X25519, } @@ -423,11 +428,34 @@ enum PasswordHashFunctionKind { Custom(Box), } +/// Builder for the stronghold plugin. +/// +/// It defines how the password sent by the frontend is hashed into the key that +/// encrypts the snapshot file. pub struct Builder { password_hash_function: PasswordHashFunctionKind, } impl Builder { + /// Initializes [`Self`] with a custom password hash function. + /// + /// The function is called with the password sent by the frontend and must return the + /// key used to encrypt the snapshot, which must be 32 bytes long. + /// + /// # Examples + /// + /// ```rust + /// fn init(builder: tauri::Builder) -> tauri::Builder { + /// builder.plugin( + /// tauri_plugin_stronghold::Builder::new(|_password| { + /// // hash the password with a secure algorithm such as argon2 or blake2b + /// // and return the resulting 32 bytes hash + /// unimplemented!() + /// }) + /// .build(), + /// ) + /// } + /// ``` pub fn new Vec + Send + Sync + 'static>(password_hash_function: F) -> Self { Self { password_hash_function: PasswordHashFunctionKind::Custom(Box::new( @@ -442,16 +470,19 @@ impl Builder { /// /// ```rust /// use tauri::Manager; - /// tauri::Builder::default() - /// .setup(|app| { + /// + /// fn init(builder: tauri::Builder) -> tauri::Builder { + /// builder.setup(|app| { /// let salt_path = app /// .path() /// .app_local_data_dir() /// .expect("could not resolve app local data path") /// .join("salt.txt"); - /// app.handle().plugin(tauri_plugin_stronghold::Builder::with_argon2(&salt_path).build())?; + /// app.handle() + /// .plugin(tauri_plugin_stronghold::Builder::with_argon2(&salt_path).build())?; /// Ok(()) - /// }); + /// }) + /// } /// ``` #[cfg(feature = "kdf")] pub fn with_argon2(salt_path: &std::path::Path) -> Self { @@ -460,6 +491,8 @@ impl Builder { } } + /// Builds the plugin, registering the password hash function and the commands used + /// by the JavaScript guest bindings. pub fn build(self) -> TauriPlugin { let password_hash_function = self.password_hash_function; diff --git a/plugins/stronghold/src/stronghold.rs b/plugins/stronghold/src/stronghold.rs index a5cadc26e..3e8568488 100644 --- a/plugins/stronghold/src/stronghold.rs +++ b/plugins/stronghold/src/stronghold.rs @@ -2,22 +2,35 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Types to load, modify and persist an [IOTA Stronghold](https://github.com/iotaledger/stronghold.rs) +//! snapshot file. + use std::{convert::TryFrom, ops::Deref, path::Path}; use iota_stronghold::{KeyProvider, SnapshotPath}; use serde::{Serialize, Serializer}; use zeroize::Zeroizing; +/// Alias for a [`std::result::Result`] with the error type [`Error`]. pub type Result = std::result::Result; +/// Errors returned by the stronghold plugin. +/// +/// Serialized as the error message string when returned to the frontend. #[derive(Debug, thiserror::Error)] pub enum Error { + /// No stronghold was initialized for the given snapshot path. #[error("stronghold not initialized")] StrongholdNotInitialized, + /// An error from the underlying Stronghold client, e.g. when loading a snapshot + /// with the wrong password or when addressing a client that does not exist. #[error(transparent)] Stronghold(#[from] iota_stronghold::ClientError), + /// An error from the Stronghold secure memory implementation, e.g. when the + /// password hash is not a key size Stronghold accepts. #[error(transparent)] Memory(#[from] iota_stronghold::MemoryError), + /// A Stronghold procedure (key generation, key derivation, signing, ...) failed. #[error(transparent)] Procedure(#[from] iota_stronghold::procedures::ProcedureError), } @@ -31,6 +44,10 @@ impl Serialize for Error { } } +/// A Stronghold instance bound to a snapshot file and to the key it is encrypted with. +/// +/// Dereferences to the underlying [`iota_stronghold::Stronghold`], so all of its client +/// and vault operations are available on this type. pub struct Stronghold { inner: iota_stronghold::Stronghold, path: SnapshotPath, @@ -38,6 +55,18 @@ pub struct Stronghold { } impl Stronghold { + /// Creates a Stronghold instance for the snapshot file at `path`, encrypted with + /// `password` as the key. + /// + /// When the file already exists its snapshot is loaded, which requires `password` to + /// be the key it was encrypted with. Otherwise an empty instance is created and + /// nothing is written to disk until [`Self::save`] is called. + /// + /// # Errors + /// + /// Returns [`Error::Memory`] when `password` is not a key size Stronghold accepts + /// (it must be 32 bytes long) and [`Error::Stronghold`] when an existing snapshot + /// cannot be loaded with it. pub fn new>(path: P, password: Vec) -> Result { let path = SnapshotPath::from_path(path); let stronghold = iota_stronghold::Stronghold::default(); @@ -52,12 +81,15 @@ impl Stronghold { }) } + /// Writes the state of all clients to the snapshot file, encrypted with the key + /// this instance was created with. pub fn save(&self) -> Result<()> { self.inner .commit_with_keyprovider(&self.path, &self.keyprovider)?; Ok(()) } + /// Returns a reference to the underlying [`iota_stronghold::Stronghold`] instance. pub fn inner(&self) -> &iota_stronghold::Stronghold { &self.inner } diff --git a/plugins/updater/guest-js/index.ts b/plugins/updater/guest-js/index.ts index c0f023bbb..4afbb0f56 100644 --- a/plugins/updater/guest-js/index.ts +++ b/plugins/updater/guest-js/index.ts @@ -2,12 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * In-app updates for Tauri applications: check the configured endpoints for a new release, + * download it and install it. + * + * @module + */ + import { invoke, Channel, Resource } from '@tauri-apps/api/core' /** Options used when checking for updates */ interface CheckOptions { /** - * Request headers + * The headers to send along with the update check request. */ headers?: HeadersInit /** @@ -27,7 +34,7 @@ interface CheckOptions { /** Options used when downloading an update */ interface DownloadOptions { /** - * Request headers + * The headers to send along with the update download request. */ headers?: HeadersInit /** @@ -59,17 +66,58 @@ type DownloadEvent = | { event: 'Progress'; data: { chunkLength: number } } | { event: 'Finished' } +/** + * An update announced by the update server, as returned by {@linkcode check}. + * + * It holds a resource on the Rust side, so call {@linkcode Update.close} when you are done with it + * without installing it. + * + * @since 2.0.0 + */ class Update extends Resource { // TODO: remove this field in v3 - /** @deprecated This is always true, check if the return value is `null` instead when using {@linkcode check} */ + /** + * Whether an update is available. + * + * @deprecated This is always true, check if the return value is `null` instead when using {@linkcode check} + */ available: boolean + /** + * The version of the application that is currently running. + */ currentVersion: string + /** + * The version announced by the update server. + */ version: string + /** + * The publish date of the update as an RFC 3339 string, when the server provided one. + */ date?: string + /** + * The release notes of the update, when the server provided them. + */ body?: string + /** + * The raw update manifest returned by the server, useful when it contains + * additional fields that the updater itself does not handle. + */ rawJson: Record private downloadedBytes?: Resource + /** + * Creates an update from the metadata returned by the backend. + * You should not need to call this yourself, use {@linkcode check} instead. + * + * @param metadata The update information returned by the backend, including the resource identifier of the update. + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * // the update instance is created for you by `check` + * const update = await check(); + * ``` + */ constructor(metadata: UpdateMetadata) { super(metadata.rid) this.available = true @@ -80,7 +128,29 @@ class Update extends Resource { this.rawJson = metadata.rawJson } - /** Download the updater package. Call {@linkcode install} later to install it */ + /** + * Downloads the updater package. Call {@linkcode install} later to install it. + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * + * const update = await check(); + * if (update) { + * let downloaded = 0; + * await update.download((event) => { + * if (event.event === 'Progress') { + * downloaded += event.data.chunkLength; + * console.log(`downloaded ${downloaded} bytes`); + * } + * }); + * await update.install(); + * } + * ``` + * + * @param onEvent Callback invoked with a `Started` event when the first chunk is received, a `Progress` event for every downloaded chunk and a `Finished` event when the download completes. + * @param options The headers and the timeout to use for the download request. + */ async download( onEvent?: (progress: DownloadEvent) => void, options?: DownloadOptions @@ -105,6 +175,19 @@ class Update extends Resource { * * - **Windows:** This function exits the app after launching the updater installer successfully * - **macOS / Linux:** You need to relaunch the app to run the newly install version + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * + * const update = await check(); + * if (update) { + * await update.download(); + * await update.install(); + * } + * ``` + * + * @param options Options for the installation, such as whether the Windows installer should restart the app afterwards. */ async install(options?: InstallOptions): Promise { if (!this.downloadedBytes) { @@ -128,6 +211,21 @@ class Update extends Resource { * * - **Windows:** This function exits the app after launching the updater installer successfully * - **macOS / Linux:** You need to relaunch the app to run the newly install version + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * + * const update = await check(); + * if (update) { + * await update.downloadAndInstall((event) => { + * console.log(event.event); + * }); + * } + * ``` + * + * @param onEvent Callback invoked with a `Started` event when the first chunk is received, a `Progress` event for every downloaded chunk and a `Finished` event when the download completes. + * @param options The headers and the timeout to use for the download request, and the installation options. */ async downloadAndInstall( onEvent?: (progress: DownloadEvent) => void, @@ -145,13 +243,45 @@ class Update extends Resource { }) } + /** + * Releases the update resource and the downloaded bytes held by the backend. + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * + * const update = await check(); + * if (update) { + * await update.close(); + * } + * ``` + */ async close(): Promise { await this.downloadedBytes?.close() await super.close() } } -/** Check for updates, resolves to `null` if no updates are available */ +/** + * Checks the configured endpoints for an available update. + * + * @example + * ```typescript + * import { check } from '@tauri-apps/plugin-updater'; + * + * const update = await check(); + * if (update) { + * console.log(`update ${update.version} is available`); + * await update.downloadAndInstall(); + * } + * ``` + * + * @param options The headers, timeout, proxy and target to use for the update check request. + * + * @returns A promise resolving to the available {@linkcode Update}, or `null` when no update is available. + * + * @since 2.0.0 + */ async function check(options?: CheckOptions): Promise { convertToRustHeaders(options) diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 6621fe553..916220be7 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -66,24 +66,35 @@ pub enum Error { /// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file. #[error("temp directory is not on the same mount point as the AppImage")] TempDirNotOnSameMountPoint, + /// The downloaded archive does not contain a binary for the current target. #[error("binary for the current target not found in the archive")] BinaryNotFoundInArchive, + /// Could not create a temporary directory to store the downloaded update. #[error("failed to create temporary directory")] TempDirNotFound, + /// The privilege escalation prompt shown before installing a Linux package + /// failed or was dismissed by the user. #[error("Authentication failed or was cancelled")] AuthenticationFailed, + /// Installing the downloaded `.deb` package failed. #[error("Failed to install .deb package")] DebInstallFailed, + /// The package manager could not install the downloaded Linux package. #[error("Failed to install package")] PackageInstallFailed, + /// The downloaded update is not in a format the updater can install on the current platform. #[error("invalid updater binary format")] InvalidUpdaterFormat, + /// `http` crate errors. #[error(transparent)] Http(#[from] http::Error), + /// A request header value is not valid. #[error(transparent)] InvalidHeaderValue(#[from] http::header::InvalidHeaderValue), + /// A request header name is not valid. #[error(transparent)] InvalidHeaderName(#[from] http::header::InvalidHeaderName), + /// The release publish date could not be formatted as an RFC 3339 string. #[error("Failed to format date")] FormatDate, /// The configured updater endpoint must use a secure protocol like `https` @@ -105,6 +116,7 @@ pub enum Error { "The update signature does not specify the version it was signed for, which `requireSignedVersion` requires. Re-sign and re-publish this release, or disable `requireSignedVersion`." )] MissingSignedVersion, + /// Tauri errors. #[error(transparent)] Tauri(#[from] tauri::Error), } @@ -118,4 +130,5 @@ impl Serialize for Error { } } +/// A [`Result`](std::result::Result) alias whose error type is the updater [`Error`](enum@Error). pub type Result = std::result::Result; diff --git a/plugins/updater/src/lib.rs b/plugins/updater/src/lib.rs index 18f2a11a5..8e58a4029 100644 --- a/plugins/updater/src/lib.rs +++ b/plugins/updater/src/lib.rs @@ -137,6 +137,21 @@ struct UpdaterState { headers: HeaderMap, } +/// Builder for the updater plugin. +/// +/// The values set here are the defaults used by every [`Updater`] created through +/// [`UpdaterExt::updater`] and [`UpdaterExt::updater_builder`]; they can still be overridden +/// per updater instance on the [`UpdaterBuilder`]. +/// +/// # Examples +/// +/// ```no_run +/// use tauri::Runtime; +/// +/// fn register_updater(builder: tauri::Builder) -> tauri::Builder { +/// builder.plugin(tauri_plugin_updater::Builder::new().build()) +/// } +/// ``` #[derive(Default)] pub struct Builder { target: Option, @@ -147,15 +162,26 @@ pub struct Builder { } impl Builder { + /// Creates a new builder with the default configuration. pub fn new() -> Self { Self::default() } + /// Sets the target name used when checking for updates. + /// + /// It replaces the `{{target}}` variable in the endpoint URLs and is used as the key to look + /// up the release in the `platforms` object of a static update manifest. + /// + /// When it is not set, the updater uses the current operating system name (`linux`, `darwin` + /// or `windows`) in the endpoint URLs and looks for `{os}-{arch}-{bundle_type}` then + /// `{os}-{arch}` in the manifest. pub fn target(mut self, target: impl Into) -> Self { self.target.replace(target.into()); self } + /// Sets the public key used to verify the update signature, + /// overriding the `pubkey` value of the plugin configuration. pub fn pubkey>(mut self, pubkey: S) -> Self { self.pubkey.replace(pubkey.into()); self @@ -189,6 +215,11 @@ impl Builder { self } + /// Adds a header to be sent on every updater request. + /// + /// # Errors + /// + /// Returns an error if the header name or the header value is not valid. pub fn header(mut self, key: K, value: V) -> Result where HeaderName: TryFrom, @@ -204,11 +235,22 @@ impl Builder { Ok(self) } + /// Replaces all the headers sent on updater requests with the given map, + /// discarding the ones previously added with [`Self::header`]. pub fn headers(mut self, headers: HeaderMap) -> Self { self.headers = headers; self } + /// Sets the default function used to decide whether a remote release should be installed. + /// + /// The closure receives the current application version and the remote release, + /// and must return `true` when the release should be treated as an update. + /// + /// It applies to every updater created through [`UpdaterExt`] and takes precedence over the + /// `allowDowngrades` configuration value; it can still be overridden per updater instance with + /// [`UpdaterBuilder::version_comparator`]. When no comparator is set at all, a release is + /// installed only if its version is greater than the current one. pub fn default_version_comparator< F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static, >( @@ -219,6 +261,10 @@ impl Builder { self } + /// Builds the updater plugin, registering the `check`, `download`, `install` + /// and `download_and_install` commands used by the JavaScript API. + /// + /// Pass the returned plugin to [`tauri::Builder::plugin`]. pub fn build(self) -> TauriPlugin { let pubkey = self.pubkey; let target = self.target; diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs index cf65df501..d04a19cdb 100644 --- a/plugins/updater/src/updater.rs +++ b/plugins/updater/src/updater.rs @@ -43,15 +43,26 @@ use crate::{ const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); +/// The kind of bundle the running application was installed from. +/// +/// Its name is appended to the updater target string (`{os}-{arch}-{bundle_type}`) when looking +/// up the release in the update manifest and replaces the `{{bundle_type}}` variable in the +/// endpoint URLs. #[derive(Copy, Clone)] pub enum Installer { + /// Linux AppImage bundle, named `appimage`. AppImage, + /// Debian package, named `deb`. Deb, + /// RPM package, named `rpm`. Rpm, + /// macOS application bundle, named `app`. Also used for applications distributed as DMG. App, + /// Windows WiX (MSI) installer, named `msi`. Msi, + /// Windows NSIS installer, named `nsis`. Nsis, } @@ -68,6 +79,7 @@ impl Installer { } } +/// The update information of a single platform in the update manifest. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ReleaseManifestPlatform { /// Download URL for the platform @@ -76,11 +88,18 @@ pub struct ReleaseManifestPlatform { pub signature: String, } +/// The platform specific data of a [`RemoteRelease`], in either of the two supported shapes. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum RemoteReleaseInner { + /// Server Format: the endpoint resolved the platform itself and returned a single + /// download URL and signature. Dynamic(ReleaseManifestPlatform), + /// Static Format: the manifest describes every platform it supports and the updater + /// picks the entry matching the current target. Static { + /// Update information for each platform, keyed by the updater target string + /// (e.g. `darwin-aarch64`). platforms: HashMap, }, } @@ -126,8 +145,16 @@ impl RemoteRelease { } } +/// Function executed right before the Windows installer is spawned and the app exits. +/// See [`UpdaterBuilder::on_before_exit`]. pub type OnBeforeExit = Arc; +/// Function that customizes the `reqwest` client builder used for the updater requests. +/// See [`UpdaterBuilder::configure_client`]. pub type OnBeforeRequest = Arc ClientBuilder + Send + Sync + 'static>; +/// Function that decides whether a remote release must be installed. +/// +/// It receives the current application version and the remote release, +/// and returns `true` when the release should be treated as an update. pub type VersionComparator = Arc bool + Send + Sync>; #[cfg(target_os = "macos")] type MainThreadClosure = Box; @@ -155,6 +182,10 @@ struct UpdaterContext { restart_after_install: bool, } +/// Builder for an [`Updater`] instance. +/// +/// Get one from [`crate::UpdaterExt::updater_builder`], which pre-fills it with the plugin +/// configuration, then call [`UpdaterBuilder::build`]. pub struct UpdaterBuilder { current_version: Version, pub(crate) version_comparator: Option, @@ -208,6 +239,12 @@ impl UpdaterBuilder { } } + /// Sets the function used to decide whether the remote release must be installed, + /// replacing the comparator set with [`crate::Builder::default_version_comparator`] + /// and the behavior of the `allowDowngrades` configuration value. + /// + /// When no comparator is set, a release is only installed if its version is greater + /// than the current application version. pub fn version_comparator bool + Send + Sync + 'static>( mut self, f: F, @@ -216,11 +253,29 @@ impl UpdaterBuilder { self } + /// Sets the target name used when checking for updates. + /// + /// It replaces the `{{target}}` variable in the endpoint URLs and is used as the key to look + /// up the release in the `platforms` object of a static update manifest. + /// + /// When it is not set, the updater uses the current operating system name (`linux`, `darwin` + /// or `windows`) in the endpoint URLs and looks for `{os}-{arch}-{bundle_type}` then + /// `{os}-{arch}` in the manifest. pub fn target(mut self, target: impl Into) -> Self { self.target.replace(target.into()); self } + /// Sets the endpoints to fetch the update manifest from, + /// overriding the `endpoints` configuration value. + /// + /// They are checked in order and the first one that returns a valid release wins. + /// + /// # Errors + /// + /// Returns [`Error::InsecureTransportProtocol`] on release builds if an endpoint does not use + /// the `https` protocol and the `dangerousInsecureTransportProtocol` configuration value is + /// not enabled. On debug builds a warning is printed instead. pub fn endpoints(mut self, endpoints: Vec) -> Result { crate::config::validate_endpoints( &endpoints, @@ -231,11 +286,19 @@ impl UpdaterBuilder { Ok(self) } + /// Sets the path of the application executable, which is used to determine where the update + /// must be installed. Defaults to the path of the current executable, or to the AppImage path + /// when the application runs as an AppImage. pub fn executable_path>(mut self, p: P) -> Self { self.executable_path.replace(p.as_ref().into()); self } + /// Adds a header to be sent on the update check and download requests. + /// + /// # Errors + /// + /// Returns an error if the header name or the header value is not valid. pub fn header(mut self, key: K, value: V) -> Result where HeaderName: TryFrom, @@ -251,21 +314,28 @@ impl UpdaterBuilder { Ok(self) } + /// Replaces all the headers sent on the update check and download requests with the given map, + /// discarding the ones previously added with [`Self::header`]. pub fn headers(mut self, headers: HeaderMap) -> Self { self.headers = headers; self } + /// Removes all the headers previously set on this builder. pub fn clear_headers(mut self) -> Self { self.headers.clear(); self } + /// Sets the timeout of the update check and download requests. + /// When it is not set, the requests do not time out. pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } + /// Sets the proxy used for the update check and download requests. + /// It is ignored when [`Self::no_proxy`] was called. pub fn proxy(mut self, proxy: Url) -> Self { self.proxy.replace(proxy); self @@ -277,6 +347,8 @@ impl UpdaterBuilder { self } + /// Sets the public key used to verify the update signature, + /// overriding the `pubkey` value of the plugin configuration. pub fn pubkey>(mut self, pubkey: S) -> Self { self.context.config.pubkey = pubkey.into(); self @@ -362,6 +434,15 @@ impl UpdaterBuilder { self } + /// Builds the [`Updater`]. + /// + /// # Errors + /// + /// - [`Error::EmptyEndpoints`]: neither [`Self::endpoints`] nor the `endpoints` + /// configuration value provided an endpoint to check. + /// - [`Error::UnsupportedArch`]: the updater does not support the current architecture. + /// - [`Error::FailedToDetermineExtractPath`]: the install directory could not be resolved + /// from the executable path. pub fn build(self) -> Result { let endpoints = self .endpoints @@ -412,6 +493,9 @@ impl UpdaterBuilder { } } +/// Checks the configured endpoints for an application update. +/// +/// Get one from [`crate::UpdaterExt::updater`] or by calling [`UpdaterBuilder::build`]. pub struct Updater { current_version: Version, version_comparator: Option, @@ -429,6 +513,25 @@ pub struct Updater { } impl Updater { + /// Checks the endpoints for an update, returning the first release that the version + /// comparator accepts. + /// + /// Each endpoint is requested in order, with the `{{current_version}}`, `{{target}}`, + /// `{{arch}}` and `{{bundle_type}}` variables replaced in its URL, until one of them + /// answers with a release manifest the updater can parse. + /// + /// Resolves to `None` when an endpoint replies with `204 No Content` or when the release it + /// announced is not considered an update - by default when its version is not greater than the + /// current application version. + /// + /// # Errors + /// + /// - [`Error::UnsupportedOs`]: no target was set and the updater does not support the + /// current operating system. + /// - [`Error::ReleaseNotFound`]: no endpoint returned a release manifest. + /// - The last request or deserialization error when every endpoint failed. + /// - [`Error::TargetNotFound`] or [`Error::TargetsNotFound`]: the manifest has no entry + /// for the current target. pub async fn check(&self) -> Result> { // we want JSON only let mut headers = self.headers.clone(); @@ -638,6 +741,10 @@ impl Updater { } } +/// An update announced by the remote server, returned by [`Updater::check`]. +/// +/// Use [`Update::download`] followed by [`Update::install`], or [`Update::download_and_install`], +/// to apply it. #[derive(Clone)] pub struct Update { /// Update description @@ -1427,6 +1534,14 @@ fn updater_arch() -> Option<&'static str> { } } +/// Resolves the path the update must be installed to from the path of the application executable. +/// +/// This is the directory holding the executable, except on macOS where the `.app` bundle path is +/// returned for executables living in `Contents/MacOS`. +/// +/// # Errors +/// +/// Returns [`Error::FailedToDetermineExtractPath`] when the path has no parent directory. pub fn extract_path_from_executable(executable_path: &Path) -> Result { // Return the path of the current executable by default // Example C:\Program Files\My App\ diff --git a/plugins/upload/guest-js/index.ts b/plugins/upload/guest-js/index.ts index c9f1863ad..2783cbe91 100644 --- a/plugins/upload/guest-js/index.ts +++ b/plugins/upload/guest-js/index.ts @@ -2,23 +2,89 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Upload files from disk to a remote server over HTTP, and download files from a remote HTTP + * server to disk. + * + * @module + */ + import { invoke, Channel } from '@tauri-apps/api/core' +/** + * The payload sent to a {@link ProgressHandler} while an upload or download is in progress. + */ interface ProgressPayload { + /** + * The number of bytes transferred since the previous progress event (i.e. the size of the + * last chunk), not the cumulative total. + */ progress: number + /** + * The cumulative number of bytes transferred so far. + */ progressTotal: number + /** + * The total size of the transfer in bytes. For a download this is the value of the response's + * `Content-Length` header, and is `0` if the server did not send it or the response body is + * compressed. For an upload this is the size of the file being uploaded. + */ total: number + /** + * The current transfer speed, approximately in bytes per second. It is recalculated about + * every 500 milliseconds and stays at `0` until then. + */ transferSpeed: number } +/** + * A callback invoked with a {@link ProgressPayload} every time a chunk of data is + * uploaded or downloaded. + */ type ProgressHandler = (progress: ProgressPayload) => void +/** + * The HTTP method used to send the file to the server in {@link upload}. + */ enum HttpMethod { + /** + * Send the file using an HTTP `POST` request. This is the default when no method is given. + */ Post = 'POST', + /** + * Send the file using an HTTP `PUT` request. + */ Put = 'PUT', + /** + * Send the file using an HTTP `PATCH` request. + */ Patch = 'PATCH' } +/** + * Uploads a file at the given path to a URL, using the file's contents as the request body. + * + * @example + * ```typescript + * import { upload } from '@tauri-apps/plugin-upload'; + * + * await upload( + * 'https://example.com/file-upload', + * './path/to/my/file.txt', + * ({ progress, total }) => console.log(`Uploaded ${progress} of ${total} bytes`), + * { 'Content-Type': 'text/plain' } + * ); + * ``` + * + * @param url The URL to upload the file to. + * @param filePath The path of the file to upload. + * @param progressHandler A callback invoked with upload progress updates. + * @param headers Additional request headers to send with the upload. + * @param method The HTTP method used to send the file. Defaults to {@link HttpMethod.Post}. + * @returns A promise resolving to the response body as text. + * + * @since 2.0.0 + */ async function upload( url: string, filePath: string, @@ -46,10 +112,32 @@ async function upload( }) } -/// Download file from given url. -/// -/// Note that `filePath` currently must include the file name. -/// Furthermore the progress events will report a total length of 0 if the server did not sent a `Content-Length` header or if the file is compressed. +/** + * Downloads a file from a given URL and writes it to the given path on disk. + * + * @example + * ```typescript + * import { download } from '@tauri-apps/plugin-upload'; + * + * await download( + * 'https://example.com/file-download-link', + * './path/to/save/my/file.txt', + * ({ progress, total }) => console.log(`Downloaded ${progress} of ${total} bytes`), + * { 'Content-Type': 'text/plain' } + * ); + * ``` + * + * @param url The URL to download the file from. + * @param filePath The path to save the file to. It must include the file name. + * @param progressHandler A callback invoked with download progress updates. The reported + * `total` will be `0` if the server did not send a `Content-Length` header or the response body + * is compressed. + * @param headers Additional request headers to send with the download request. + * @param body An optional request body. When provided, the download is requested with an HTTP + * `POST` request using this value as the body; otherwise an HTTP `GET` request is used. + * + * @since 2.0.0 + */ async function download( url: string, filePath: string, diff --git a/plugins/upload/src/lib.rs b/plugins/upload/src/lib.rs index 33f83288d..7cffb358f 100644 --- a/plugins/upload/src/lib.rs +++ b/plugins/upload/src/lib.rs @@ -38,24 +38,39 @@ use read_progress_stream::ReadProgressStream; use std::collections::HashMap; +/// The HTTP method used to send the file in the `upload` command. +/// +/// Serialized as an uppercase string (`"POST"`, `"PUT"` or `"PATCH"`) to match the JavaScript +/// guest bindings. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum HttpMethod { + /// Send the file with an HTTP `POST` request. This is the default when no method is given. Post, + /// Send the file with an HTTP `PUT` request. Put, + /// Send the file with an HTTP `PATCH` request. Patch, } type Result = std::result::Result; +/// The error type returned by this plugin's `upload` and `download` commands. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error, for example failing to open, read or write the file at the given path, or + /// the background task that performs the transfer panicking. #[error(transparent)] Io(#[from] std::io::Error), + /// An error returned by the underlying [`reqwest`] HTTP client while sending the request or + /// reading the response. #[error(transparent)] Request(#[from] reqwest::Error), + /// The content length of the request or response could not be determined. #[error("{0}")] ContentLength(String), + /// The HTTP response did not indicate success. Contains the status code and the response + /// body text. #[error("request failed with status code {0}: {1}")] HttpErrorCode(u16, String), } @@ -193,6 +208,7 @@ fn file_to_body(channel: Channel, file: File, file_len: u64) -> )) } +/// Initializes the upload plugin, registering the `upload` and `download` commands. pub fn init() -> TauriPlugin { PluginBuilder::new("upload") .invoke_handler(tauri::generate_handler![download, upload]) diff --git a/plugins/websocket/guest-js/index.ts b/plugins/websocket/guest-js/index.ts index 7ea7b3265..ca0af4420 100644 --- a/plugins/websocket/guest-js/index.ts +++ b/plugins/websocket/guest-js/index.ts @@ -2,8 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Open a WebSocket connection using a Rust client in JS. + * + * @module + */ + import { invoke, Channel } from '@tauri-apps/api/core' +/** + * Configuration used to open a WebSocket connection, forwarded to the underlying `tungstenite` client. + */ export interface ConnectionConfig { /** * Read buffer capacity. The default value is 128 KiB. @@ -39,16 +48,29 @@ export interface ConnectionConfig { headers?: HeadersInit } +/** + * A tagged WebSocket message, discriminated by its `type` field. + */ export interface MessageKind { + /** The kind of message, e.g. `'Text'`, `'Binary'`, `'Ping'`, `'Pong'` or `'Close'`. */ type: T + /** The message payload, whose shape depends on {@link MessageKind.type}. */ data: D } +/** + * The code and reason sent (or received) when a WebSocket connection is closed. + */ export interface CloseFrame { + /** The WebSocket close status code, e.g. `1000` for a normal closure. */ code: number + /** A human-readable explanation for why the connection was closed. */ reason: string } +/** + * A message sent to or received from a WebSocket connection. + */ export type Message = | MessageKind<'Text', string> | MessageKind<'Binary', number[]> @@ -56,15 +78,52 @@ export type Message = | MessageKind<'Pong', number[]> | MessageKind<'Close', CloseFrame | null> +/** + * A WebSocket connection, created with {@link WebSocket.connect}. + * + * @since 2.0.0 + */ export default class WebSocket { + /** The identifier of the underlying connection managed by the Rust side. */ id: number private readonly listeners: Set<(arg: Message) => void> + /** + * Creates a {@link WebSocket} wrapper around an already-open connection. + * + * This is used internally by {@link WebSocket.connect}; use that instead of calling this + * constructor directly. + * + * @param id The identifier of the connection returned by the Rust side. + * @param listeners The set of callbacks to notify when a message is received. + * @example + * ```typescript + * import WebSocket from '@tauri-apps/plugin-websocket'; + * + * // internally used by `WebSocket.connect`; prefer calling that instead + * const ws = await WebSocket.connect('wss://example.com'); + * ``` + */ constructor(id: number, listeners: Set<(arg: Message) => void>) { this.id = id this.listeners = listeners } + /** + * Opens a WebSocket connection to the given URL. + * @example + * ```typescript + * import WebSocket from '@tauri-apps/plugin-websocket'; + * + * const ws = await WebSocket.connect('wss://example.com'); + * ``` + * + * @param url The URL to connect to, e.g. `ws://` or `wss://` (the latter requires one of the + * plugin's TLS Cargo features to be enabled). + * @param config Configuration forwarded to the underlying `tungstenite` client. + * @returns A promise resolving to the connected {@link WebSocket}. + * @since 2.0.0 + */ static async connect( url: string, config?: ConnectionConfig @@ -89,6 +148,20 @@ export default class WebSocket { }).then((id) => new WebSocket(id, listeners)) } + /** + * Adds a listener that is called whenever a message is received on this connection, including + * an error message (as a `'Close'` message) when the underlying stream fails. + * @example + * ```typescript + * import WebSocket from '@tauri-apps/plugin-websocket'; + * + * const ws = await WebSocket.connect('wss://example.com'); + * const unlisten = ws.addListener((message) => console.log(message)); + * ``` + * + * @param cb The callback invoked with each received {@link Message}. + * @returns A function that removes the listener when called. + */ addListener(cb: (arg: Message) => void): () => void { this.listeners.add(cb) @@ -97,6 +170,22 @@ export default class WebSocket { } } + /** + * Sends a message through the WebSocket connection. + * @example + * ```typescript + * import WebSocket from '@tauri-apps/plugin-websocket'; + * + * const ws = await WebSocket.connect('wss://example.com'); + * await ws.send('Hello World'); + * await ws.send([1, 2, 3]); + * await ws.send({ type: 'Text', data: 'Hello World' }); + * ``` + * + * @param message The message to send: a plain string (sent as a `'Text'` message), a numeric + * array (sent as a `'Binary'` message), or an explicit {@link Message} object. + * @returns A promise resolving when the message has been sent. + */ async send(message: Message | string | number[]): Promise { let m: Message if (typeof message === 'string') { @@ -116,6 +205,16 @@ export default class WebSocket { }) } + /** + * Closes the WebSocket connection, sending a normal closure (`1000`) close frame to the server. + * @example + * ```typescript + * import WebSocket from '@tauri-apps/plugin-websocket'; + * + * const ws = await WebSocket.connect('wss://example.com'); + * await ws.disconnect(); + * ``` + */ async disconnect(): Promise { await this.send({ type: 'Close', diff --git a/plugins/websocket/src/lib.rs b/plugins/websocket/src/lib.rs index 135be836c..15c7fa2b4 100644 --- a/plugins/websocket/src/lib.rs +++ b/plugins/websocket/src/lib.rs @@ -274,27 +274,36 @@ async fn send( } } +/// Initializes the plugin with the default [`Builder`], i.e. without a custom TLS [`Connector`]. pub fn init() -> TauriPlugin { Builder::default().build() } +/// Builder for the WebSocket plugin, used to configure a custom TLS [`Connector`] before calling [`Builder::build`]. #[derive(Default)] pub struct Builder { tls_connector: Option, } impl Builder { + /// Creates a new [`Builder`] with no custom TLS [`Connector`] configured. pub fn new() -> Self { Self { tls_connector: None, } } + /// Sets the TLS [`Connector`] used to establish `wss://` connections. + /// + /// When this is not called (or is called with [`Connector::Plain`]) and a `rustls-tls` or + /// `rustls-tls-native-roots` feature is enabled, [`Builder::build`] installs `rustls`'s `ring` + /// crypto provider as the process default if none is installed yet. pub fn tls_connector(mut self, connector: Connector) -> Self { self.tls_connector.replace(connector); self } + /// Builds the plugin, registering the `connect` and `send` commands and the shared connection state. pub fn build(self) -> TauriPlugin { PluginBuilder::new("websocket") .invoke_handler(tauri::generate_handler![connect, send]) diff --git a/plugins/window-state/guest-js/index.ts b/plugins/window-state/guest-js/index.ts index 1cbd57332..a2351a316 100644 --- a/plugins/window-state/guest-js/index.ts +++ b/plugins/window-state/guest-js/index.ts @@ -2,21 +2,48 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +/** + * Save and restore window positions and sizes. + * + * @module + */ + import { invoke } from '@tauri-apps/api/core' import { type WindowLabel, getCurrentWindow } from '@tauri-apps/api/window' +/** + * Flags controlling which parts of a window's state are saved and restored. + * Combine multiple flags with the bitwise OR operator (`|`). + */ export enum StateFlags { + /** Save and restore the window size. */ SIZE = 1 << 0, + /** Save and restore the window position. */ POSITION = 1 << 1, + /** Save and restore whether the window is maximized. */ MAXIMIZED = 1 << 2, + /** Save and restore whether the window is visible. */ VISIBLE = 1 << 3, + /** Save and restore whether the window has decorations. */ DECORATIONS = 1 << 4, + /** Save and restore whether the window is fullscreen. */ FULLSCREEN = 1 << 5, + /** Save and restore every flag above. */ ALL = SIZE | POSITION | MAXIMIZED | VISIBLE | DECORATIONS | FULLSCREEN } /** * Save the state of all open windows to disk. + * + * @example + * ```typescript + * import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'; + * + * await saveWindowState(StateFlags.ALL); + * ``` + * + * @param flags Which parts of the state to save. Defaults to the flags passed to the plugin `Builder` (all flags if none were set). + * @since 2.0.0 */ async function saveWindowState(flags?: StateFlags): Promise { await invoke('plugin:window-state|save_window_state', { flags }) @@ -24,6 +51,17 @@ async function saveWindowState(flags?: StateFlags): Promise { /** * Restore the state for the specified window from disk. + * + * @example + * ```typescript + * import { restoreState, StateFlags } from '@tauri-apps/plugin-window-state'; + * + * await restoreState('main', StateFlags.ALL); + * ``` + * + * @param label The label of the window to restore. + * @param flags Which parts of the state to restore. Defaults to the flags passed to the plugin `Builder` (all flags if none were set). + * @since 2.0.0 */ async function restoreState( label: WindowLabel, @@ -34,12 +72,32 @@ async function restoreState( /** * Restore the state for the current window from disk. + * + * @example + * ```typescript + * import { restoreStateCurrent, StateFlags } from '@tauri-apps/plugin-window-state'; + * + * await restoreStateCurrent(StateFlags.ALL); + * ``` + * + * @param flags Which parts of the state to restore. Defaults to the flags passed to the plugin `Builder` (all flags if none were set). + * @since 2.0.0 */ async function restoreStateCurrent(flags?: StateFlags): Promise { await restoreState(getCurrentWindow().label, flags) } /** * Get the name of the file used to store window state. + * + * @example + * ```typescript + * import { filename } from '@tauri-apps/plugin-window-state'; + * + * const name = await filename(); + * ``` + * + * @returns A promise resolving to the name of the file used to store window state. + * @since 2.0.0 */ async function filename(): Promise { return await invoke('plugin:window-state|filename') diff --git a/plugins/window-state/src/lib.rs b/plugins/window-state/src/lib.rs index f3e75848a..21c5a2350 100644 --- a/plugins/window-state/src/lib.rs +++ b/plugins/window-state/src/lib.rs @@ -35,26 +35,41 @@ type FilterCallbackFn = dyn Fn(&str) -> bool + Send + Sync; /// If using a custom filename, you should probably use [`AppHandleExt::filename`] instead. pub const DEFAULT_FILENAME: &str = ".window-state.json"; +/// The error type returned by this plugin's fallible functions. #[derive(Debug, thiserror::Error)] pub enum Error { + /// An I/O error occurred while reading or writing the window state file. #[error(transparent)] Io(#[from] std::io::Error), + /// An error occurred while calling into Tauri, e.g. while resolving the app config directory + /// or manipulating a window. #[error(transparent)] Tauri(#[from] tauri::Error), + /// An error occurred while serializing or deserializing the window state file. #[error(transparent)] SerdeJson(#[from] serde_json::Error), } +/// Alias for a [`Result`](std::result::Result) with the error type [`Error`]. pub type Result = std::result::Result; bitflags! { + /// Flags controlling which parts of a window's state are saved and restored. + /// + /// Flags can be combined with the bitwise OR operator (`|`), e.g. `StateFlags::SIZE | StateFlags::POSITION`. #[derive(Clone, Copy, Debug)] pub struct StateFlags: u32 { + /// Save and restore the window size. const SIZE = 1 << 0; + /// Save and restore the window position. const POSITION = 1 << 1; + /// Save and restore whether the window is maximized. const MAXIMIZED = 1 << 2; + /// Save and restore whether the window is visible. const VISIBLE = 1 << 3; + /// Save and restore whether the window has decorations. const DECORATIONS = 1 << 4; + /// Save and restore whether the window is fullscreen. const FULLSCREEN = 1 << 5; } } @@ -110,6 +125,7 @@ struct WindowStateCache(Arc>>); /// Used to prevent deadlocks from resize and position event listeners setting the cached state on restoring states struct RestoringWindowState(Mutex<()>); +/// Extension trait for [`AppHandle`] exposing window state APIs. pub trait AppHandleExt { /// Saves all open windows state to disk fn save_window_state(&self, flags: StateFlags) -> Result<()>; @@ -151,6 +167,7 @@ impl AppHandleExt for tauri::AppHandle { } } +/// Extension trait for [`Window`] and [`WebviewWindow`] exposing window state APIs. pub trait WindowExt { /// Restores this window state from disk fn restore_state(&self, flags: StateFlags) -> tauri::Result<()>; @@ -321,6 +338,7 @@ impl WindowExtInternal for Window { } } +/// Builder for the window-state [plugin](TauriPlugin). #[derive(Default)] pub struct Builder { denylist: HashSet, @@ -332,6 +350,8 @@ pub struct Builder { } impl Builder { + /// Creates a new [`Builder`] with the default configuration: + /// all [`StateFlags`] enabled, no denylist, no filter, and the [`DEFAULT_FILENAME`]. pub fn new() -> Self { Self::default() } @@ -382,6 +402,12 @@ impl Builder { self } + /// Builds the [`TauriPlugin`]. + /// + /// The plugin loads the previously saved window state on setup, restores each window's + /// state when it becomes ready (unless denylisted, filtered out, or opted out with + /// [`Builder::skip_initial_state`]), tracks move/resize events to keep the state up to date, + /// and saves the state of all windows to disk when the app exits. pub fn build(self) -> TauriPlugin { let state_flags = self.state_flags; let filename = self.filename.unwrap_or_else(|| DEFAULT_FILENAME.into());