From f19edfc39eaef54015d073712e30b5f4eeadc8ef Mon Sep 17 00:00:00 2001 From: takahashim Date: Fri, 6 Feb 2026 20:18:54 +0900 Subject: [PATCH] feat(biometric): add macOS Touch ID support via LocalAuthentication framework --- .changes/biometric-macos.md | 5 + 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 +- plugins/biometric/Cargo.toml | 10 +- plugins/biometric/README.md | 4 +- plugins/biometric/src/commands.rs | 39 ++++ plugins/biometric/src/desktop.rs | 181 ++++++++++++++++++ plugins/biometric/src/error.rs | 6 + plugins/biometric/src/lib.rs | 55 ++---- plugins/biometric/src/mobile.rs | 50 +++++ plugins/biometric/src/models.rs | 4 +- 14 files changed, 332 insertions(+), 45 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 0000000000..830967a27a --- /dev/null +++ b/.changes/biometric-macos.md @@ -0,0 +1,5 @@ +--- +"biometric": minor:feat +--- + +Add macOS Touch ID support via LocalAuthentication framework. diff --git a/Cargo.lock b/Cargo.lock index 291c2a3ce6..850b4346d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4033,6 +4033,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.3", + "objc2-foundation 0.3.0", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -6646,7 +6657,11 @@ dependencies = [ name = "tauri-plugin-biometric" version = "2.3.2" dependencies = [ + "block2 0.6.2", "log", + "objc2 0.6.3", + "objc2-foundation 0.3.0", + "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 afbb0c4593..874b60ba49 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ tauri-plugin-opener = { path = "../../../plugins/opener", version = "2.5.3" } tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.5" } tauri-plugin-store = { path = "../../../plugins/store", version = "2.4.2" } tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" } +tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.2" } [dependencies.tauri] workspace = true @@ -63,7 +64,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.4.4" } tauri-plugin-nfc = { path = "../../../plugins/nfc", version = "2.3.4" } -tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.2" } tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = "2.3.2" } tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "2.3.2" } diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json index 82d8354f53..d203fc4591 100644 --- a/examples/api/src-tauri/capabilities/desktop.json +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -7,6 +7,8 @@ "permissions": [ "cli: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 3c58f2c81d..b3965aeb84 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -30,6 +30,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()) @@ -56,7 +57,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 8e114c4b9d..c954a623ee 100644 --- a/examples/api/src/App.svelte +++ b/examples/api/src/App.svelte @@ -133,7 +133,7 @@ component: Nfc, icon: 'i-ph-nfc' }, - isMobile && { + { label: 'Biometric', component: Biometric, icon: 'i-ph-scan' diff --git a/plugins/biometric/Cargo.toml b/plugins/biometric/Cargo.toml index 42cf22ab9d..2b10bf85e1 100644 --- a/plugins/biometric/Cargo.toml +++ b/plugins/biometric/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tauri-plugin-biometric" version = "2.3.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 } authors = { workspace = true } license = { workspace = true } @@ -14,7 +14,7 @@ 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 = "" } @@ -29,3 +29,9 @@ tauri = { workspace = true } 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"] } diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md index e2ad7efde9..381950abc4 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/src/commands.rs b/plugins/biometric/src/commands.rs new file mode 100644 index 0000000000..e51a5cb73d --- /dev/null +++ b/plugins/biometric/src/commands.rs @@ -0,0 +1,39 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use tauri::{command, AppHandle, Runtime, State}; + +use crate::{models::*, Biometric, Result}; + +#[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, + biometric: State<'_, Biometric>, + 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, + }; + biometric.authenticate(reason, options) +} diff --git a/plugins/biometric/src/desktop.rs b/plugins/biometric/src/desktop.rs new file mode 100644 index 0000000000..5b5fb028e4 --- /dev/null +++ b/plugins/biometric/src/desktop.rs @@ -0,0 +1,181 @@ +// 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::{plugin::PluginApi, AppHandle, Runtime}; + +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( + "Biometric authentication is not supported on this platform".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, 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(); + let code = map_la_error_code(error.code()); + + Ok(Status { + is_available: false, + biometry_type: convert_biometry_type(biometry_type), + error: Some(desc), + error_code: Some(code), + }) + } + } + } + + 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 { + if !options.allow_device_credential { + let desc = error.localizedDescription().to_string(); + let code = map_la_error_code(error.code()); + return Err(crate::Error::Unavailable(format!("[{code}] {desc}"))); + } + } + + // 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_msg = if !error.is_null() { + let e = unsafe { &*error }; + let desc = e.localizedDescription().to_string(); + let code = map_la_error_code(e.code()); + format!("[{code}] {desc}") + } else { + "Authentication failed".to_string() + }; + tx.send(Err(crate::Error::AuthenticationFailed(err_msg))) + .ok(); + } + }); + + unsafe { + context.evaluatePolicy_localizedReason_reply(policy, &reason, &block); + } + + rx.recv() + .map_err(|_| crate::Error::AuthenticationFailed("Channel closed".into()))? + } + + 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 + } + } + + fn map_la_error_code(code: isize) -> String { + match code { + -1 => "authenticationFailed", + -2 => "userCancel", + -3 => "userFallback", + -4 => "systemCancel", + -5 => "passcodeNotSet", + -6 => "appCancel", + -7 => "biometryNotAvailable", + -8 => "biometryNotEnrolled", + -9 => "biometryLockout", + -10 => "invalidContext", + -1004 => "notInteractive", + _ => "unknown", + } + .to_string() + } +} diff --git a/plugins/biometric/src/error.rs b/plugins/biometric/src/error.rs index 339e763b1f..057d4fa48d 100644 --- a/plugins/biometric/src/error.rs +++ b/plugins/biometric/src/error.rs @@ -13,6 +13,12 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + #[cfg(desktop)] + #[error("Biometric authentication failed: {0}")] + AuthenticationFailed(String), + #[cfg(desktop)] + #[error("Biometric unavailable: {0}")] + Unavailable(String), } impl Serialize for Error { diff --git a/plugins/biometric/src/lib.rs b/plugins/biometric/src/lib.rs index f79a104d34..20b051ab6b 100644 --- a/plugins/biometric/src/lib.rs +++ b/plugins/biometric/src/lib.rs @@ -2,48 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -#![cfg(mobile)] - -use serde::Serialize; use tauri::{ - plugin::{Builder, PluginHandle, TauriPlugin}, + plugin::{Builder, TauriPlugin}, Manager, Runtime, }; pub use models::*; +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 { - pub fn status(&self) -> crate::Result { - self.0.run_mobile_plugin("status", ()).map_err(Into::into) - } - - 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 { @@ -59,12 +38,16 @@ impl> crate::BiometricExt for T { /// Initializes the plugin. pub fn init() -> TauriPlugin { Builder::new("biometric") + .invoke_handler(tauri::generate_handler![ + commands::status, + commands::authenticate, + ]) .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 0000000000..80250c8cce --- /dev/null +++ b/plugins/biometric/src/mobile.rs @@ -0,0 +1,50 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{de::DeserializeOwned, Serialize}; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +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 { + pub fn status(&self) -> crate::Result { + self.0.run_mobile_plugin("status", ()).map_err(Into::into) + } + + 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 49c8430042..7bdbc9892a 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -21,7 +21,7 @@ pub struct AuthOptions { pub confirmation_required: Option, } -#[derive(Debug, Clone, serde_repr::Deserialize_repr)] +#[derive(Debug, Clone, serde_repr::Deserialize_repr, serde_repr::Serialize_repr)] #[repr(u8)] pub enum BiometryType { None = 0, @@ -29,7 +29,7 @@ pub enum BiometryType { FaceID = 2, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Status { pub is_available: bool,