Skip to main content

azul_layout/managers/
biometric.rs

1//! Biometric manager — cross-platform state for the biometric-auth
2//! surface (SUPER_PLAN_2 §1 feature 4 + research/02).
3//!
4//! **Request-driven**, unlike the continuous `GeolocationManager`. The
5//! three callers are:
6//!
7//! - A **callback** invokes `App::request_biometric_auth(prompt)` (e.g.
8//!   the AzulVault unlock button). The OS draws its own modal sheet; the
9//!   app cannot skin it.
10//!
11//! - The **platform backend** (`dll/src/desktop/extra/biometric/<plat>.rs`)
12//!   shows the prompt (iOS / macOS `LAContext.evaluatePolicy`, Android
13//!   `BiometricPrompt.authenticate`, Windows `UserConsentVerifier`, Linux
14//!   polkit / PAM) and, when the user responds, parks the outcome in the
15//!   async result channel [`push_biometric_result`]. It also writes the
16//!   sync availability probe via [`BiometricManager::set_availability`].
17//!
18//! - The dll **layout pass** drains the channel once per frame via
19//!   [`drain_biometric_results`] and applies the latest through
20//!   [`BiometricManager::set_last_result`]; callbacks then read it with
21//!   `CallbackInfo::get_biometric_result()` and the device capability via
22//!   the sync availability accessor.
23//!
24//! No platform deps (SUPER_PLAN_2 §0.5); the async-result channel is
25//! copied verbatim from `geolocation.rs`.
26
27use alloc::vec::Vec;
28
29// `BiometricKind` / `BiometricResult` / `BiometricPrompt` live in
30// `azul-core` so the request config can cross the FFI without a cyclic
31// dep on `azul-layout`. Re-exported here for the existing
32// `azul_layout::managers::biometric::*` import paths.
33pub use azul_core::biometric::{BiometricKind, BiometricPrompt, BiometricResult};
34
35/// Cross-platform biometric state. One per `App` — the OS exposes a
36/// single per-process authentication surface, not per-window.
37#[derive(Debug, Clone, PartialEq)]
38pub struct BiometricManager {
39    /// Outcome of the most recent `request_biometric_auth`, or `None`
40    /// until the first request completes. Read by callbacks via
41    /// `CallbackInfo::get_biometric_result()`.
42    pub last_result: Option<BiometricResult>,
43    /// Cached sync availability probe — what the device *can* do
44    /// (`Face` / `Fingerprint` / `Iris` / `NotAvailable`). The backend
45    /// refreshes it on startup and after enrollment changes; callbacks
46    /// read it to decide whether to even offer biometric unlock.
47    pub availability: BiometricKind,
48}
49
50impl Default for BiometricManager {
51    fn default() -> Self {
52        Self {
53            last_result: None,
54            availability: BiometricKind::NotAvailable,
55        }
56    }
57}
58
59impl BiometricManager {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Most recent auth outcome, or `None` until the first request
65    /// resolves.
66    pub fn last_result(&self) -> Option<BiometricResult> {
67        self.last_result
68    }
69
70    /// Device capability probe (sync). `NotAvailable` until the backend
71    /// reports otherwise.
72    pub fn availability(&self) -> BiometricKind {
73        self.availability
74    }
75
76    /// `true` if the device has a usable biometric sensor.
77    pub fn is_available(&self) -> bool {
78        self.availability.is_available()
79    }
80
81    /// Platform backend records the device's biometric capability.
82    /// Returns `true` if it changed, so the caller can relayout to
83    /// reflect a newly-enrolled (or newly-removed) sensor.
84    pub fn set_availability(&mut self, kind: BiometricKind) -> bool {
85        let changed = self.availability != kind;
86        self.availability = kind;
87        changed
88    }
89
90    /// Apply the outcome the backend delivered for the user's request.
91    /// Returns `true` if it differs from the previous outcome (so the
92    /// window can be marked dirty to re-render the unlocked / denied
93    /// state).
94    pub fn set_last_result(&mut self, result: BiometricResult) -> bool {
95        let changed = self.last_result != Some(result);
96        self.last_result = Some(result);
97        changed
98    }
99
100    /// `true` if the last attempt unlocked successfully (biometric match
101    /// or OS passcode fallback). Convenience for the vault gate.
102    pub fn last_was_success(&self) -> bool {
103        matches!(self.last_result, Some(r) if r.is_success())
104    }
105}
106
107// ────────── Async result channel (platform backend → manager) ─────────
108//
109// The OS prompt's reply block / `AuthenticationCallback` fires on an
110// arbitrary thread with no handle to the live `BiometricManager` (it
111// lives inside the window's `LayoutWindow`). The backend parks each
112// result here; the layout pass drains it once per frame via
113// [`drain_biometric_results`] and applies the latest through
114// [`BiometricManager::set_last_result`]. Pure Rust — no platform
115// dependency (SUPER_PLAN_2 §0.5). Mirrors the geolocation manager's
116// async-fix channel.
117
118static PENDING_RESULTS: std::sync::Mutex<Vec<BiometricResult>> =
119    std::sync::Mutex::new(Vec::new());
120
121/// Park a biometric result delivered by a platform backend (in the dll).
122/// Thread-safe; recovers from a poisoned lock so one panicking applier
123/// can't wedge delivery forever.
124pub fn push_biometric_result(result: BiometricResult) {
125    let mut q = PENDING_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
126    q.push(result);
127}
128
129/// Drain every result parked by [`push_biometric_result`], in arrival
130/// order. Called once per layout pass; the caller applies them through
131/// [`BiometricManager::set_last_result`] (the last one wins).
132pub fn drain_biometric_results() -> Vec<BiometricResult> {
133    let mut q = PENDING_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
134    core::mem::take(&mut *q)
135}
136
137// ────────── Request channel (callback → platform backend) ─────────────
138//
139// The reverse direction: a callback (e.g. an unlock button's `on_click`)
140// calls `CallbackInfo::request_biometric_auth(prompt)`, which parks the
141// prompt here. The dll layout pass drains it via
142// [`drain_biometric_requests`] and dispatches each to the native backend
143// (`dll::desktop::extra::biometric::request`), which shows the OS prompt
144// and later parks the outcome back through [`push_biometric_result`].
145// Decoupling via a channel keeps the request callable from any callback
146// without threading the window's backend handle through `CallbackInfo`,
147// and keeps `azul-layout` platform-free (SUPER_PLAN_2 §0.5).
148
149static PENDING_REQUESTS: std::sync::Mutex<Vec<BiometricPrompt>> =
150    std::sync::Mutex::new(Vec::new());
151
152/// Queue a biometric-auth request from a callback. Picked up by the dll
153/// layout pass and dispatched to the native prompt. Thread-safe;
154/// poison-recovering.
155pub fn push_biometric_request(prompt: BiometricPrompt) {
156    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(|e| e.into_inner());
157    q.push(prompt);
158}
159
160/// Drain every request queued by [`push_biometric_request`], in arrival
161/// order. Called once per layout pass; the dll dispatches each to the
162/// platform backend.
163pub fn drain_biometric_requests() -> Vec<BiometricPrompt> {
164    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(|e| e.into_inner());
165    core::mem::take(&mut *q)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn manager_defaults_to_unavailable_and_no_result() {
174        let mgr = BiometricManager::new();
175        assert_eq!(mgr.availability(), BiometricKind::NotAvailable);
176        assert!(!mgr.is_available());
177        assert_eq!(mgr.last_result(), None);
178        assert!(!mgr.last_was_success());
179    }
180
181    #[test]
182    fn set_availability_returns_change_flag() {
183        let mut mgr = BiometricManager::new();
184        assert!(mgr.set_availability(BiometricKind::Face));
185        assert!(mgr.is_available());
186        assert_eq!(mgr.availability(), BiometricKind::Face);
187        // Same value again — no change.
188        assert!(!mgr.set_availability(BiometricKind::Face));
189        // Different value — change.
190        assert!(mgr.set_availability(BiometricKind::Fingerprint));
191    }
192
193    #[test]
194    fn set_last_result_returns_change_flag() {
195        let mut mgr = BiometricManager::new();
196        assert!(mgr.set_last_result(BiometricResult::Failed));
197        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
198        assert!(!mgr.last_was_success());
199        // Re-applying the same outcome is not a change.
200        assert!(!mgr.set_last_result(BiometricResult::Failed));
201        // A new outcome is a change, and Authenticated is a success.
202        assert!(mgr.set_last_result(BiometricResult::Authenticated));
203        assert!(mgr.last_was_success());
204    }
205
206    #[test]
207    fn passcode_fallback_counts_as_success() {
208        let mut mgr = BiometricManager::new();
209        mgr.set_last_result(BiometricResult::FellBackToPasscode);
210        assert!(mgr.last_was_success());
211        assert!(BiometricResult::FellBackToPasscode.is_success());
212        // Cancelled / Failed / Unavailable / Error are not successes.
213        for r in [
214            BiometricResult::Cancelled,
215            BiometricResult::Failed,
216            BiometricResult::Unavailable,
217            BiometricResult::Error,
218        ] {
219            assert!(!r.is_success(), "{:?} must not be a success", r);
220        }
221    }
222
223    #[test]
224    fn async_results_round_trip_through_manager() {
225        // The channel is process-global; clear any residue first.
226        let _ = drain_biometric_results();
227
228        push_biometric_result(BiometricResult::Failed);
229        push_biometric_result(BiometricResult::Authenticated); // last wins
230        let drained = drain_biometric_results();
231        assert_eq!(drained.len(), 2, "both parked results drain in order");
232        assert_eq!(drained[0], BiometricResult::Failed);
233        assert_eq!(drained[1], BiometricResult::Authenticated);
234
235        // Applying them reflects in last_result() — what the layout pass does.
236        let mut mgr = BiometricManager::new();
237        for r in &drained {
238            mgr.set_last_result(*r);
239        }
240        assert_eq!(
241            mgr.last_result(),
242            Some(BiometricResult::Authenticated),
243            "the last applied result wins"
244        );
245        assert!(mgr.last_was_success());
246
247        // A second drain is empty — the queue was taken, not copied.
248        assert!(drain_biometric_results().is_empty());
249    }
250
251    #[test]
252    fn requests_round_trip_through_channel() {
253        // Process-global; clear residue first.
254        let _ = drain_biometric_requests();
255
256        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
257        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
258        let drained = drain_biometric_requests();
259        assert_eq!(drained.len(), 2, "both queued requests drain in order");
260        assert_eq!(drained[0].reason.as_str(), "Unlock A");
261        assert_eq!(drained[1].reason.as_str(), "Unlock B");
262
263        // A second drain is empty — the queue was taken, not copied.
264        assert!(drain_biometric_requests().is_empty());
265    }
266
267    #[test]
268    fn biometric_prompt_defaults_and_constructor() {
269        let d = BiometricPrompt::default();
270        assert!(!d.allow_device_credential);
271        assert_eq!(d.reason.as_str(), "");
272
273        let p = BiometricPrompt::new("Unlock your vault".into());
274        assert_eq!(p.reason.as_str(), "Unlock your vault");
275        assert_eq!(p.cancel_label.as_str(), "");
276        assert!(!p.allow_device_credential);
277    }
278}