From 2ebe54abce3494f0f021383b0a32f9b3c3b80fa2 Mon Sep 17 00:00:00 2001 From: Masayoshi Takahashi Date: Mon, 12 Oct 2026 09:56:15 +0900 Subject: [PATCH] feat(biometric): add macOS Touch ID support via LocalAuthentication framework (#3264) * feat(biometric): add macOS Touch ID support via LocalAuthentication framework * cleanup * fmt * change file * fix tests --------- Co-authored-by: Lucas Nogueira --- .changes/biometric-macos.md | 6 + Cargo.lock | 15 ++ examples/api/src-tauri/Cargo.toml | 2 +- .../api/src-tauri/capabilities/desktop.json | 2 + examples/api/src-tauri/src/lib.rs | 2 +- examples/api/src/App.svelte | 2 +- packages/api-e2e/test/helpers/index.ts | 6 +- packages/api-e2e/test/specs/plugins.spec.ts | 2 +- plugins/biometric/Cargo.toml | 19 +- plugins/biometric/README.md | 4 +- plugins/biometric/guest-js/index.ts | 12 +- plugins/biometric/src/commands.rs | 44 ++++ plugins/biometric/src/desktop.rs | 191 ++++++++++++++++++ plugins/biometric/src/error.rs | 63 +++++- plugins/biometric/src/lib.rs | 71 +++---- plugins/biometric/src/mobile.rs | 56 +++++ plugins/biometric/src/models.rs | 10 +- 17 files changed, 439 insertions(+), 68 deletions(-) create mode 100644 .changes/biometric-macos.md create mode 100644 plugins/biometric/src/commands.rs create mode 100644 plugins/biometric/src/desktop.rs create mode 100644 plugins/biometric/src/mobile.rs diff --git a/.changes/biometric-macos.md b/.changes/biometric-macos.md new file mode 100644 index 000000000..88e229091 --- /dev/null +++ b/.changes/biometric-macos.md @@ -0,0 +1,6 @@ +--- +"biometric": minor:feat +"biometric-js": minor:feat +--- + +Add macOS Touch ID support via LocalAuthentication framework. diff --git a/Cargo.lock b/Cargo.lock index 9fa94c052..427a7cbb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4209,6 +4209,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-local-authentication" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc4c269ebb5210f5ae57ce061e08ec05cf35a6fb4a5d8287c7d4b1776ea6700" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -6722,7 +6733,11 @@ dependencies = [ name = "tauri-plugin-biometric" version = "2.4.2" dependencies = [ + "block2 0.6.2", "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-local-authentication", "serde", "serde_json", "serde_repr", diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 090f72810..56bf6cb0e 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -42,6 +42,7 @@ tauri-plugin-sql = { path = "../../../plugins/sql", version = "2.4.1", features "sqlite", ] } tauri-plugin-websocket = { path = "../../../plugins/websocket", version = "2.4.3" } +tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.4.1" } # WebDriver automation bridge, used by the plugins e2e suite (packages/api-e2e). # Desktop-only and behind the off-by-default `automation` feature so it never ships in a @@ -76,7 +77,6 @@ tauri-plugin-window-state = { path = "../../../plugins/window-state", version = [target."cfg(any(target_os = \"android\", target_os = \"ios\"))".dependencies] tauri-plugin-barcode-scanner = { path = "../../../plugins/barcode-scanner/", version = "2.5.2" } tauri-plugin-nfc = { path = "../../../plugins/nfc", version = "2.4.2" } -tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.4.2" } tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = "2.4.2" } tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "2.4.2" } diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json index 26722c2fd..dbb27f53f 100644 --- a/examples/api/src-tauri/capabilities/desktop.json +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -9,6 +9,8 @@ "cli:default", "positioner:default", "updater:default", + "biometric:allow-authenticate", + "biometric:allow-status", "global-shortcut:allow-unregister", "global-shortcut:allow-register", "global-shortcut:allow-unregister-all", diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 5d51ba5bd..118ed685b 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_biometric::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_http::init()) @@ -92,7 +93,6 @@ pub fn run() { { app.handle().plugin(tauri_plugin_barcode_scanner::init())?; app.handle().plugin(tauri_plugin_nfc::init())?; - app.handle().plugin(tauri_plugin_biometric::init())?; app.handle().plugin(tauri_plugin_geolocation::init())?; app.handle().plugin(tauri_plugin_haptics::init())?; } diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte index 205b46f07..97abd8703 100644 --- a/examples/api/src/App.svelte +++ b/examples/api/src/App.svelte @@ -171,7 +171,7 @@ component: Nfc, icon: 'i-ph-nfc' }, - isMobile && { + { label: 'Biometric', component: Biometric, icon: 'i-ph-scan' diff --git a/packages/api-e2e/test/helpers/index.ts b/packages/api-e2e/test/helpers/index.ts index 323614d89..fd7345cbb 100644 --- a/packages/api-e2e/test/helpers/index.ts +++ b/packages/api-e2e/test/helpers/index.ts @@ -39,6 +39,7 @@ import type * as WindowState from '@tauri-apps/plugin-window-state' * only have a default export, so their global is that class itself. */ export interface CommonPluginApi { + biometric: typeof Biometric clipboardManager: typeof ClipboardManager deepLink: typeof DeepLink dialog: typeof Dialog @@ -69,7 +70,6 @@ export interface DesktopPluginApi { /** The plugin APIs the example only registers on mobile (`#[cfg(mobile)]`). */ export interface MobilePluginApi { barcodeScanner: typeof BarcodeScanner - biometric: typeof Biometric geolocation: typeof Geolocation haptics: typeof Haptics nfc: typeof Nfc @@ -265,8 +265,8 @@ export interface DescribePluginOptions { desktopOnly?: boolean /** * The example only registers the plugin on mobile (`barcode-scanner`, - * `biometric`, `geolocation`, `haptics`, `nfc`), so the whole suite is - * skipped on desktop. + * `geolocation`, `haptics`, `nfc`), so the whole suite is skipped on + * desktop. */ mobileOnly?: boolean } diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts index 70b7bc0bc..42673fa5d 100644 --- a/packages/api-e2e/test/specs/plugins.spec.ts +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -23,6 +23,7 @@ type Surface = { [P in keyof T]: (keyof T[P])[] } /** Plugins the example registers on every platform. */ const commonSurface: Surface = { + biometric: ['BiometryType', 'checkStatus', 'authenticate'], clipboardManager: [ 'writeText', 'readText', @@ -144,7 +145,6 @@ const mobileSurface: Surface = { 'requestPermissions', 'openAppSettings' ], - biometric: ['BiometryType', 'checkStatus', 'authenticate'], geolocation: [ 'watchPosition', 'getCurrentPosition', diff --git a/plugins/biometric/Cargo.toml b/plugins/biometric/Cargo.toml index fd5d474b7..c92faff89 100644 --- a/plugins/biometric/Cargo.toml +++ b/plugins/biometric/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tauri-plugin-biometric" version = "2.4.2" -description = "Prompt the user for biometric authentication on Android and iOS." +description = "Prompt the user for biometric authentication on Android, iOS, and macOS." edition = { workspace = true } rust-version = { workspace = true } authors = { workspace = true } @@ -16,11 +16,10 @@ targets = ["x86_64-linux-android"] [package.metadata.platforms.support] windows = { level = "none", notes = "" } linux = { level = "none", notes = "" } -macos = { level = "none", notes = "" } +macos = { level = "full", notes = "" } android = { level = "full", notes = "" } ios = { level = "full", notes = "" } - [build-dependencies] tauri-plugin = { workspace = true, features = ["build"] } @@ -32,5 +31,19 @@ log = { workspace = true } thiserror = { workspace = true } serde_repr = "0.1" +[target."cfg(target_os = \"macos\")".dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-foundation = { version = "0.3", default-features = false, features = [ + "NSError", + "NSString", +] } +objc2-local-authentication = { version = "0.3", default-features = false, features = [ + "LAContext", + "LAError", + "LABiometryType", + "block2", +] } + [target.'cfg(target_os = "ios")'.dependencies] tauri = { workspace = true, features = ["wry"] } diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index e2ad7efde..381950abc 100644 --- a/plugins/biometric/README.md +++ b/plugins/biometric/README.md @@ -1,12 +1,12 @@ ![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png) -Prompt the user for biometric authentication on Android and iOS. +Prompt the user for biometric authentication on Android, iOS and macOS. | Platform | Supported | | -------- | --------- | | Linux | x | | Windows | x | -| macOS | x | +| macOS | ✓ | | Android | ✓ | | iOS | ✓ | diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index cf17bc69b..cddd613c5 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT /** - * Prompt the user for biometric authentication on Android and iOS. + * Prompt the user for biometric authentication on Android, iOS and macOS. * * @module */ @@ -54,13 +54,13 @@ export interface Status { * 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. */ + /** Enables authentication using the device's password or PIN. Available on Android, iOS and macOS. */ allowDeviceCredential?: boolean - /** Label for the cancel button. Available on both Android and iOS. */ + /** Label for the cancel button. Available on Android, iOS and macOS. */ cancelTitle?: string - // iOS options - /** Text displayed on the fallback button if biometric authentication fails. **iOS only.** */ + // iOS and macOS options + /** Text displayed on the fallback button if biometric authentication fails. **iOS and macOS only.** */ fallbackTitle?: string // android options @@ -94,7 +94,7 @@ export async function checkStatus(): Promise { /** * Prompts the user for authentication using the system interface (touchID, faceID or Android Iris). - * Rejects if the authentication fails. + * Rejects if the authentication fails, with an error object containing a `message` and, when the platform provides one, a `code` (e.g. `userCancel`). * * @example * ```typescript diff --git a/plugins/biometric/src/commands.rs b/plugins/biometric/src/commands.rs new file mode 100644 index 000000000..56643ddb9 --- /dev/null +++ b/plugins/biometric/src/commands.rs @@ -0,0 +1,44 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{AppHandle, Runtime, State, command}; + +use crate::{Biometric, BiometricExt, Result, models::*}; + +#[command] +pub(crate) async fn status( + _app: AppHandle, + biometric: State<'_, Biometric>, +) -> Result { + biometric.status() +} + +#[command] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn authenticate( + app: AppHandle, + reason: String, + allow_device_credential: Option, + cancel_title: Option, + fallback_title: Option, + title: Option, + subtitle: Option, + confirmation_required: Option, +) -> Result<()> { + let options = AuthOptions { + allow_device_credential: allow_device_credential.unwrap_or(false), + cancel_title, + fallback_title, + title, + subtitle, + confirmation_required, + }; + // The prompt blocks until the user answers, so keep it off the async runtime workers. + tauri::async_runtime::spawn_blocking(move || app.biometric().authenticate(reason, options)) + .await + .map_err(|e| crate::Error::AuthenticationFailed { + message: e.to_string(), + code: None, + })? +} diff --git a/plugins/biometric/src/desktop.rs b/plugins/biometric/src/desktop.rs new file mode 100644 index 000000000..a66717cef --- /dev/null +++ b/plugins/biometric/src/desktop.rs @@ -0,0 +1,191 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::de::DeserializeOwned; +use tauri::{AppHandle, Runtime, plugin::PluginApi}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Biometric(app.clone())) +} + +/// Access to the biometric APIs. +pub struct Biometric(AppHandle); + +impl Biometric { + pub fn status(&self) -> crate::Result { + #[cfg(target_os = "macos")] + { + macos::status() + } + #[cfg(not(target_os = "macos"))] + { + Ok(Status { + is_available: false, + biometry_type: BiometryType::None, + error: Some("Biometric authentication is not supported on this platform".into()), + error_code: Some("biometryNotAvailable".into()), + }) + } + } + + pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result<()> { + #[cfg(target_os = "macos")] + { + macos::authenticate(reason, options) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (reason, options); + Err(crate::Error::Unavailable { + message: "Biometric authentication is not supported on this platform".into(), + code: Some("biometryNotAvailable".into()), + }) + } + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::*; + use block2::RcBlock; + use objc2::runtime::Bool; + use objc2_foundation::{NSError, NSString}; + use objc2_local_authentication::{LABiometryType, LAContext, LAError, LAPolicy}; + + pub fn status() -> crate::Result { + let context = unsafe { LAContext::new() }; + let result = unsafe { + context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + }; + let biometry_type = unsafe { context.biometryType() }; + + match result { + Ok(()) => Ok(Status { + is_available: true, + biometry_type: convert_biometry_type(biometry_type), + error: None, + error_code: None, + }), + Err(error) => { + let desc = error.localizedDescription().to_string(); + // Same fallback as the iOS plugin for codes outside the documented set. + let code = map_la_error_code(error.code()).unwrap_or("biometryNotAvailable"); + + Ok(Status { + is_available: false, + biometry_type: convert_biometry_type(biometry_type), + error: Some(desc), + error_code: Some(code.into()), + }) + } + } + } + + pub fn authenticate(reason: String, options: AuthOptions) -> crate::Result<()> { + let context = unsafe { LAContext::new() }; + + // Pre-check: if biometry is unavailable and device credential fallback is disabled, + // return early with the error. + let can_evaluate = unsafe { + context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + }; + if let Err(error) = can_evaluate + && !options.allow_device_credential + { + let code = map_la_error_code(error.code()).unwrap_or("biometryNotAvailable"); + return Err(crate::Error::Unavailable { + message: error.localizedDescription().to_string(), + code: Some(code.into()), + }); + } + + // Set localized titles + if let Some(ref fallback_title) = options.fallback_title { + let title = NSString::from_str(fallback_title); + unsafe { context.setLocalizedFallbackTitle(Some(&title)) }; + } + if options.allow_device_credential && matches!(options.fallback_title.as_deref(), Some("")) + { + unsafe { context.setLocalizedFallbackTitle(None) }; + } + if let Some(ref cancel_title) = options.cancel_title { + let title = NSString::from_str(cancel_title); + unsafe { context.setLocalizedCancelTitle(Some(&title)) }; + } + + // Disable authentication reuse + unsafe { context.setTouchIDAuthenticationAllowableReuseDuration(0.0) }; + + let reason = NSString::from_str(&reason); + let policy = if options.allow_device_credential { + LAPolicy::DeviceOwnerAuthentication + } else { + LAPolicy::DeviceOwnerAuthenticationWithBiometrics + }; + + let (tx, rx) = std::sync::mpsc::channel(); + let block = RcBlock::new(move |success: Bool, error: *mut NSError| { + if success.as_bool() { + tx.send(Ok(())).ok(); + } else { + let err = if !error.is_null() { + let e = unsafe { &*error }; + crate::Error::AuthenticationFailed { + message: e.localizedDescription().to_string(), + code: map_la_error_code(e.code()).map(Into::into), + } + } else { + crate::Error::AuthenticationFailed { + message: "Unknown error".into(), + code: Some("authenticationFailed".into()), + } + }; + tx.send(Err(err)).ok(); + } + }); + + unsafe { + context.evaluatePolicy_localizedReason_reply(policy, &reason, &block); + } + + rx.recv().map_err(|_| crate::Error::AuthenticationFailed { + message: "Channel closed".into(), + code: None, + })? + } + + fn convert_biometry_type(biometry_type: LABiometryType) -> BiometryType { + if biometry_type == LABiometryType::TouchID { + BiometryType::TouchID + } else if biometry_type == LABiometryType::FaceID { + BiometryType::FaceID + } else { + BiometryType::None + } + } + + /// Maps an `LAError` code to the identifiers documented in the JS API, matching the iOS plugin. + fn map_la_error_code(code: isize) -> Option<&'static str> { + let code = match LAError(code) { + LAError::AuthenticationFailed => "authenticationFailed", + LAError::UserCancel => "userCancel", + LAError::UserFallback => "userFallback", + LAError::SystemCancel => "systemCancel", + LAError::PasscodeNotSet => "passcodeNotSet", + LAError::BiometryNotAvailable => "biometryNotAvailable", + LAError::BiometryNotEnrolled => "biometryNotEnrolled", + LAError::BiometryLockout => "biometryLockout", + LAError::AppCancel => "appCancel", + LAError::InvalidContext => "invalidContext", + LAError::NotInteractive => "notInteractive", + _ => return None, + }; + Some(code) + } +} diff --git a/plugins/biometric/src/error.rs b/plugins/biometric/src/error.rs index 8fbd0bd11..c4bc9c0a6 100644 --- a/plugins/biometric/src/error.rs +++ b/plugins/biometric/src/error.rs @@ -19,6 +19,24 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + /// The authentication prompt failed or was canceled by the user or the system. + #[cfg(desktop)] + #[error("Biometric authentication failed: {message}")] + AuthenticationFailed { + /// A human-readable description of the failure, as reported by the operating system. + message: String, + /// The error code, using the same identifiers as [`Status::error_code`](crate::Status::error_code). + code: Option, + }, + /// Biometric authentication cannot be used on this device. + #[cfg(desktop)] + #[error("Biometric unavailable: {message}")] + Unavailable { + /// A human-readable reason why biometric authentication is unavailable. + message: String, + /// The error code, using the same identifiers as [`Status::error_code`](crate::Status::error_code). + code: Option, + }, } impl Serialize for Error { @@ -26,6 +44,49 @@ impl Serialize for Error { where S: Serializer, { - serializer.serialize_str(self.to_string().as_ref()) + match self { + // Same `{ message, code }` shape the Android and iOS plugins reject with. + #[cfg(desktop)] + Self::AuthenticationFailed { message, code } | Self::Unavailable { message, code } => { + #[derive(Serialize)] + struct Payload<'a> { + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option<&'a str>, + } + Payload { + message, + code: code.as_deref(), + } + .serialize(serializer) + } + _ => serializer.serialize_str(self.to_string().as_ref()), + } + } +} + +#[cfg(all(test, desktop))] +mod tests { + use super::Error; + + #[test] + fn desktop_errors_serialize_like_mobile_rejections() { + let err = Error::AuthenticationFailed { + message: "Canceled by user.".into(), + code: Some("userCancel".into()), + }; + assert_eq!( + serde_json::to_value(&err).unwrap(), + serde_json::json!({ "message": "Canceled by user.", "code": "userCancel" }) + ); + + let err = Error::Unavailable { + message: "No biometry".into(), + code: None, + }; + assert_eq!( + serde_json::to_value(&err).unwrap(), + serde_json::json!({ "message": "No biometry" }) + ); } } diff --git a/plugins/biometric/src/lib.rs b/plugins/biometric/src/lib.rs index 03f3fc1d7..1e0a296a3 100644 --- a/plugins/biometric/src/lib.rs +++ b/plugins/biometric/src/lib.rs @@ -4,56 +4,30 @@ //! Prompt the user for biometric authentication. //! -//! - Supported platforms: Android and iOS. +//! - Supported platforms: Android, iOS and macOS. -#![cfg(mobile)] - -use serde::Serialize; use tauri::{ Manager, Runtime, - plugin::{Builder, PluginHandle, TauriPlugin}, + plugin::{Builder, TauriPlugin}, }; pub use models::*; +#[cfg(desktop)] +mod commands; +#[cfg(desktop)] +mod desktop; mod error; +#[cfg(mobile)] +mod mobile; mod models; pub use error::{Error, Result}; -#[cfg(target_os = "android")] -const PLUGIN_IDENTIFIER: &str = "app.tauri.biometric"; - -#[cfg(target_os = "ios")] -tauri::ios_plugin_binding!(init_plugin_biometric); - -/// Access to the biometric APIs. -pub struct Biometric(PluginHandle); - -#[derive(Serialize)] -struct AuthenticatePayload { - reason: String, - #[serde(flatten)] - options: AuthOptions, -} - -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 }) - .map_err(Into::into) - } -} +#[cfg(desktop)] +pub use desktop::Biometric; +#[cfg(mobile)] +pub use mobile::Biometric; /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the biometric APIs. pub trait BiometricExt { @@ -69,13 +43,22 @@ impl> crate::BiometricExt for T { /// Initializes the plugin. pub fn init() -> TauriPlugin { - Builder::new("biometric") + let builder = Builder::new("biometric"); + + // On mobile the commands are routed directly to the native plugin. + #[cfg(desktop)] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::status, + commands::authenticate, + ]); + + builder .setup(|app, api| { - #[cfg(target_os = "android")] - let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "BiometricPlugin")?; - #[cfg(target_os = "ios")] - let handle = api.register_ios_plugin(init_plugin_biometric)?; - app.manage(Biometric(handle)); + #[cfg(mobile)] + let biometric = mobile::init(app, api)?; + #[cfg(desktop)] + let biometric = desktop::init(app, api)?; + app.manage(biometric); Ok(()) }) .build() diff --git a/plugins/biometric/src/mobile.rs b/plugins/biometric/src/mobile.rs new file mode 100644 index 000000000..a6c849dc6 --- /dev/null +++ b/plugins/biometric/src/mobile.rs @@ -0,0 +1,56 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Serialize, de::DeserializeOwned}; +use tauri::{ + AppHandle, Runtime, + plugin::{PluginApi, PluginHandle}, +}; + +use crate::models::*; + +#[cfg(target_os = "android")] +const PLUGIN_IDENTIFIER: &str = "app.tauri.biometric"; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_biometric); + +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + #[cfg(target_os = "android")] + let handle = api.register_android_plugin(PLUGIN_IDENTIFIER, "BiometricPlugin")?; + #[cfg(target_os = "ios")] + let handle = api.register_ios_plugin(init_plugin_biometric)?; + Ok(Biometric(handle)) +} + +/// Access to the biometric APIs. +pub struct Biometric(PluginHandle); + +#[derive(Serialize)] +struct AuthenticatePayload { + reason: String, + #[serde(flatten)] + options: AuthOptions, +} + +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 }) + .map_err(Into::into) + } +} diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs index 33547d364..a9b541f34 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -8,11 +8,11 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthOptions { - /// Enables authentication using the device's password. This feature is available on both Android and iOS. + /// Enables authentication using the device's password. This feature is available on Android, iOS and macOS. pub allow_device_credential: bool, - /// Label for the Cancel button. This feature is available on both Android and iOS. + /// Label for the Cancel button. This feature is available on Android, iOS and macOS. pub cancel_title: Option, - /// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available iOS only. + /// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available on iOS and macOS only. pub fallback_title: Option, /// Title indicating the purpose of biometric verification. This feature is available Android only. pub title: Option, @@ -23,7 +23,7 @@ pub struct AuthOptions { } /// The kind of biometry hardware detected on the device. -#[derive(Debug, Clone, serde_repr::Deserialize_repr)] +#[derive(Debug, Clone, serde_repr::Deserialize_repr, serde_repr::Serialize_repr)] #[repr(u8)] pub enum BiometryType { /// No biometry hardware is available, or it is not enrolled with the operating system. @@ -36,7 +36,7 @@ pub enum BiometryType { /// The result of [`Biometric::status`](crate::Biometric::status), describing whether biometric /// authentication can currently be used. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Status { /// Whether the device can currently authenticate using biometrics.