diff --git a/Cargo.toml b/Cargo.toml index f63e5b1..4b9666f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,12 @@ serde_repr = "0.1" thiserror = "2" log = "0.4" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-foundation = "0.2" +objc2-local-authentication = { version = "0.2", features = ["LAContext", "LAError", "block2"] } +block2 = "0.5" + [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = ["Foundation", "Security_Credentials_UI", "Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } diff --git a/src/desktop.rs b/src/desktop.rs index e758ade..f4e49dd 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -16,37 +16,37 @@ pub struct Biometry(AppHandle); impl Biometry { pub fn status(&self) -> crate::Result { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } pub fn authenticate(&self, _reason: String, _options: AuthOptions) -> crate::Result<()> { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } pub fn has_data(&self, _options: DataOptions) -> crate::Result { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } pub fn get_data(&self, _options: GetDataOptions) -> crate::Result { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } pub fn set_data(&self, _options: SetDataOptions) -> crate::Result<()> { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } pub fn remove_data(&self, _options: RemoveDataOptions) -> crate::Result<()> { Err(crate::Error::from(std::io::Error::other( - "Biometry is not supported on desktop platforms", + "Biometry is not supported on this platform", ))) } } diff --git a/src/error.rs b/src/error.rs index 177e8c2..e9f0a2c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,50 @@ use serde::{ser::Serializer, Serialize}; pub type Result = std::result::Result; +/// Replica of the tauri::plugin::mobile::ErrorResponse for desktop platforms. +#[cfg(desktop)] +#[derive(Debug, thiserror::Error, Clone, serde::Deserialize)] +pub struct ErrorResponse { + /// Error code. + pub code: Option, + /// Error message. + pub message: Option, + /// Optional error data. + #[serde(flatten)] + pub data: T, +} + +#[cfg(desktop)] +impl std::fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(code) = &self.code { + write!(f, "[{code}]")?; + if self.message.is_some() { + write!(f, " - ")?; + } + } + if let Some(message) = &self.message { + write!(f, "{message}")?; + } + Ok(()) + } +} + +/// Replica of the tauri::plugin::mobile::PluginInvokeError for desktop platforms. +#[cfg(desktop)] +#[derive(Debug, thiserror::Error)] +pub enum PluginInvokeError { + /// Error returned from direct desktop plugin. + #[error(transparent)] + InvokeRejected(#[from] ErrorResponse), + /// Failed to deserialize response. + #[error("failed to deserialize response: {0}")] + CannotDeserializeResponse(serde_json::Error), + /// Failed to serialize request payload. + #[error("failed to serialize payload: {0}")] + CannotSerializePayload(serde_json::Error), +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -9,6 +53,9 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + #[cfg(desktop)] + #[error(transparent)] + PluginInvoke(#[from] crate::error::PluginInvokeError), } impl Serialize for Error { diff --git a/src/lib.rs b/src/lib.rs index c30b8a1..1f351aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,10 @@ use tauri::{ pub use models::*; -#[cfg(all(desktop, not(target_os = "windows")))] +#[cfg(all(desktop, not(target_os = "windows"), not(target_os = "macos")))] mod desktop; +#[cfg(target_os = "macos")] +mod macos; #[cfg(mobile)] mod mobile; #[cfg(target_os = "windows")] @@ -18,8 +20,10 @@ mod models; pub use error::{Error, Result}; -#[cfg(all(desktop, not(target_os = "windows")))] +#[cfg(all(desktop, not(target_os = "windows"), not(target_os = "macos")))] use desktop::Biometry; +#[cfg(target_os = "macos")] +use macos::Biometry; #[cfg(mobile)] use mobile::Biometry; #[cfg(target_os = "windows")] @@ -50,10 +54,12 @@ pub fn init() -> TauriPlugin { .setup(|app, api| { #[cfg(mobile)] let biometry = mobile::init(app, api)?; - #[cfg(all(desktop, not(target_os = "windows")))] + #[cfg(all(desktop, not(target_os = "windows"), not(target_os = "macos")))] let biometry = desktop::init(app, api)?; #[cfg(target_os = "windows")] let biometry = windows::init(app, api)?; + #[cfg(target_os = "macos")] + let biometry = macos::init(app, api)?; app.manage(biometry); Ok(()) }) diff --git a/src/macos.rs b/src/macos.rs new file mode 100644 index 0000000..302560c --- /dev/null +++ b/src/macos.rs @@ -0,0 +1,198 @@ +use objc2_local_authentication::{LABiometryType, LAContext, LAError, LAPolicy}; +use serde::de::DeserializeOwned; +use tauri::{plugin::PluginApi, AppHandle, Runtime}; + +use crate::models::*; + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + Ok(Biometry(app.clone())) +} + +fn la_error_to_string(error: LAError) -> &'static str { + match error { + LAError::AppCancel => "appCancel", + LAError::AuthenticationFailed => "authenticationFailed", + LAError::InvalidContext => "invalidContext", + LAError::NotInteractive => "notInteractive", + LAError::PasscodeNotSet => "passcodeNotSet", + LAError::SystemCancel => "systemCancel", + LAError::UserCancel => "userCancel", + LAError::UserFallback => "userFallback", + LAError::BiometryLockout => "biometryLockout", + LAError::BiometryNotAvailable => "biometryNotAvailable", + LAError::BiometryNotEnrolled => "biometryNotEnrolled", + _ => "unknown", + } +} + +/// Access to the biometry APIs. +pub struct Biometry(AppHandle); + +impl Biometry { + pub fn status(&self) -> crate::Result { + let context = unsafe { LAContext::new() }; + + let can_evaluate = unsafe { + context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + }; + + let biometry_type = unsafe { context.biometryType() }; + + let is_available = can_evaluate.is_ok(); + let mut error_reason: Option = None; + let mut error_code: Option = None; + + if let Err(error) = can_evaluate { + let ns_error = &*error; + + // Get error description + let description = ns_error.localizedDescription(); + error_reason = Some(description.to_string()); + + // Map error code to string representation + let code = LAError(ns_error.code()); + error_code = Some(la_error_to_string(code).to_string()); + } + + // Map LABiometryType to our BiometryType enum + let mapped_biometry_type = match biometry_type { + LABiometryType::None => BiometryType::None, + LABiometryType::TouchID => BiometryType::TouchID, + LABiometryType::FaceID => BiometryType::FaceID, + #[allow(unreachable_patterns)] + _ => BiometryType::None, + }; + + Ok(Status { + is_available, + biometry_type: mapped_biometry_type, + error: error_reason, + error_code, + }) + } + + pub fn authenticate(&self, reason: String, options: AuthOptions) -> crate::Result<()> { + let context = unsafe { LAContext::new() }; + + // Check if biometry is available or device credential is allowed + let can_evaluate_biometry = unsafe { + context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthenticationWithBiometrics) + }; + + let allow_device_credential = options.allow_device_credential.unwrap_or(false); + + if can_evaluate_biometry.is_err() && !allow_device_credential { + // Biometry unavailable and fallback disabled + if let Err(error) = can_evaluate_biometry { + let ns_error = &*error; + let description = ns_error.localizedDescription(); + let code = LAError(ns_error.code()); + let error_code = la_error_to_string(code); + + return Err(crate::Error::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{error_code}: {description}"), + ))); + } + } + + // Set localized titles if provided + if let Some(fallback_title) = options.fallback_title { + unsafe { + let title_str = objc2_foundation::NSString::from_str(&fallback_title); + context.setLocalizedFallbackTitle(Some(&title_str)); + } + } + + if let Some(cancel_title) = options.cancel_title { + unsafe { + let title_str = objc2_foundation::NSString::from_str(&cancel_title); + context.setLocalizedCancelTitle(Some(&title_str)); + } + } + + // Set authentication reuse duration to 0 (no reuse) + unsafe { + context.setTouchIDAuthenticationAllowableReuseDuration(0.0); + } + + // Determine which policy to use + let policy = if allow_device_credential { + LAPolicy::DeviceOwnerAuthentication + } else { + LAPolicy::DeviceOwnerAuthenticationWithBiometrics + }; + + // Create a channel to communicate between the callback and the main thread + let (tx, rx) = std::sync::mpsc::channel(); + + // Perform authentication + unsafe { + let reason_str = objc2_foundation::NSString::from_str(&reason); + let tx_clone = tx.clone(); + + context.evaluatePolicy_localizedReason_reply( + policy, + &reason_str, + &block2::StackBlock::new( + move |success: objc2::runtime::Bool, + error_ptr: *mut objc2_foundation::NSError| { + if success.as_bool() { + let _ = tx_clone.send(Ok(())); + } else if !error_ptr.is_null() { + let error = &*error_ptr; + let description = error.localizedDescription().to_string(); + let code = LAError(error.code()); + let error_code = la_error_to_string(code); + + let _ = tx_clone.send(Err(crate::Error::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{error_code}: {description}"), + )))); + } else { + let _ = tx_clone.send(Err(crate::Error::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "authenticationFailed: Unknown error".to_string(), + )))); + } + }, + ), + ); + } + + // Wait for authentication result + match rx.recv() { + Ok(result) => result, + Err(_) => Err(crate::Error::Io(std::io::Error::other( + "authenticationFailed: Failed to receive authentication result", + ))), + } + } + + pub fn has_data(&self, _options: DataOptions) -> crate::Result { + Err(crate::Error::from(std::io::Error::other( + "Biometry has_data is not yet implemented on macOS", + ))) + } + + pub fn get_data(&self, _options: GetDataOptions) -> crate::Result { + Err(crate::Error::from(std::io::Error::other( + "Biometry get_data is not yet implemented on macOS", + ))) + } + + pub fn set_data(&self, _options: SetDataOptions) -> crate::Result<()> { + Err(crate::Error::from(std::io::Error::other( + "Biometry set_data is not yet implemented on macOS", + ))) + } + + pub fn remove_data(&self, _options: RemoveDataOptions) -> crate::Result<()> { + Err(crate::Error::from(std::io::Error::other( + "Biometry remove_data is not yet implemented on macOS", + ))) + } +} diff --git a/src/windows.rs b/src/windows.rs index de7b7d1..73672aa 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -72,9 +72,10 @@ impl Biometry { let availability = UserConsentVerifier::CheckAvailabilityAsync() .and_then(|async_op| async_op.get()) .map_err(|e| { - crate::Error::from(std::io::Error::other( - format!("Failed to check biometry availability: {:?}", e), - )) + crate::Error::from(std::io::Error::other(format!( + "Failed to check biometry availability: {:?}", + e + ))) })?; let (is_available, biometry_type, error, error_code) = match availability { @@ -126,9 +127,10 @@ impl Biometry { async_op.get() }) .map_err(|e| { - crate::Error::from(std::io::Error::other( - format!("Failed to request user verification: {:?}", e), - )) + crate::Error::from(std::io::Error::other(format!( + "Failed to request user verification: {:?}", + e + ))) })?; match result { @@ -145,11 +147,9 @@ impl Biometry { "Biometric authentication is disabled by policy", ))) } - UserConsentVerificationResult::NotConfiguredForUser => { - Err(crate::Error::from(std::io::Error::other( - "Biometric authentication is not configured for the user", - ))) - } + UserConsentVerificationResult::NotConfiguredForUser => Err(crate::Error::from( + std::io::Error::other("Biometric authentication is not configured for the user"), + )), UserConsentVerificationResult::Canceled => { Err(crate::Error::from(std::io::Error::new( std::io::ErrorKind::Interrupted, @@ -170,25 +170,25 @@ impl Biometry { pub fn has_data(&self, _options: DataOptions) -> crate::Result { Err(crate::Error::from(std::io::Error::other( - "Has data is not supported on windows platform", + "Biometry has_data is not supported on windows platform", ))) } pub fn get_data(&self, _options: GetDataOptions) -> crate::Result { Err(crate::Error::from(std::io::Error::other( - "Get data is not supported on windows platform", + "Biometry get_data is not supported on windows platform", ))) } pub fn set_data(&self, _options: SetDataOptions) -> crate::Result<()> { Err(crate::Error::from(std::io::Error::other( - "Set data is not supported on windows platform", + "Biometry set_data is not supported on windows platform", ))) } pub fn remove_data(&self, _options: RemoveDataOptions) -> crate::Result<()> { Err(crate::Error::from(std::io::Error::other( - "Remove data is not supported on windows platform", + "Biometry remove_data is not supported on windows platform", ))) } }