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
29use azul_core::dom::DomNodeId;
30use azul_core::events::{
31    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
32};
33use azul_core::task::Instant;
34
35// `BiometricKind` / `BiometricResult` / `BiometricPrompt` live in
36// `azul-core` so the request config can cross the FFI without a cyclic
37// dep on `azul-layout`. Re-exported here for the existing
38// `azul_layout::managers::biometric::*` import paths.
39pub use azul_core::biometric::{BiometricKind, BiometricPrompt, BiometricResult};
40
41/// Cross-platform biometric state. One per `App` — the OS exposes a
42/// single per-process authentication surface, not per-window.
43#[derive(Copy, Debug, Clone, PartialEq, Eq)]
44pub struct BiometricManager {
45    /// Outcome of the most recent `request_biometric_auth`, or `None`
46    /// until the first request completes. Read by callbacks via
47    /// `CallbackInfo::get_biometric_result()`.
48    pub last_result: Option<BiometricResult>,
49    /// Cached sync availability probe — what the device *can* do
50    /// (`Face` / `Fingerprint` / `Iris` / `NotAvailable`). The backend
51    /// refreshes it on startup and after enrollment changes; callbacks
52    /// read it to decide whether to even offer biometric unlock.
53    pub availability: BiometricKind,
54    /// Prompts dispatched to the native backend whose outcome has not been
55    /// folded back yet (MWA-A1b). While non-zero the capability pump keeps
56    /// its timer armed so the reply reaches callbacks in an idle app.
57    pub in_flight: u32,
58    /// `true` when a prompt outcome was folded since the last event pass
59    /// (set on EVERY completion, even a repeated identical outcome — the
60    /// user re-authenticated and the callback must hear about it). Read by
61    /// the `EventProvider` impl (`EventType::BiometricResult`), cleared by
62    /// [`clear_pending_event`](Self::clear_pending_event).
63    pub pending_event: bool,
64}
65
66impl Default for BiometricManager {
67    fn default() -> Self {
68        Self {
69            last_result: None,
70            availability: BiometricKind::NotAvailable,
71            in_flight: 0,
72            pending_event: false,
73        }
74    }
75}
76
77impl BiometricManager {
78    #[must_use] pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Most recent auth outcome, or `None` until the first request
83    /// resolves.
84    #[must_use] pub const fn last_result(&self) -> Option<BiometricResult> {
85        self.last_result
86    }
87
88    /// Device capability probe (sync). `NotAvailable` until the backend
89    /// reports otherwise.
90    #[must_use] pub const fn availability(&self) -> BiometricKind {
91        self.availability
92    }
93
94    /// `true` if the device has a usable biometric sensor.
95    #[must_use] pub const fn is_available(&self) -> bool {
96        self.availability.is_available()
97    }
98
99    /// Platform backend records the device's biometric capability.
100    /// Returns `true` if it changed, so the caller can relayout to
101    /// reflect a newly-enrolled (or newly-removed) sensor.
102    pub fn set_availability(&mut self, kind: BiometricKind) -> bool {
103        let changed = self.availability != kind;
104        self.availability = kind;
105        changed
106    }
107
108    /// Apply the outcome the backend delivered for the user's request.
109    /// Returns `true` if it differs from the previous outcome (so the
110    /// window can be marked dirty to re-render the unlocked / denied
111    /// state).
112    pub fn set_last_result(&mut self, result: BiometricResult) -> bool {
113        let changed = self.last_result != Some(result);
114        self.last_result = Some(result);
115        // MWA-A1b: every completion fires an event (even an identical
116        // repeat outcome — it answers a fresh request) and retires one
117        // in-flight prompt.
118        self.pending_event = true;
119        self.in_flight = self.in_flight.saturating_sub(1);
120        changed
121    }
122
123    /// The pump dispatched `n` prompts to the native backend; keep the
124    /// timer armed until their outcomes fold back (MWA-A1b).
125    pub const fn mark_requests_dispatched(&mut self, n: u32) {
126        self.in_flight = self.in_flight.saturating_add(n);
127    }
128
129    /// Clear the pending-event flag. The dll calls this after the event
130    /// pass has collected the `BiometricResult` event.
131    pub const fn clear_pending_event(&mut self) {
132        self.pending_event = false;
133    }
134
135    /// `true` while a dispatched prompt's outcome is still outstanding
136    /// (MWA-A1b arming signal).
137    #[must_use] pub const fn has_pending_async(&self) -> bool {
138        self.in_flight > 0
139    }
140
141    /// `true` if the last attempt unlocked successfully (biometric match
142    /// or OS passcode fallback). Convenience for the vault gate.
143    #[must_use] pub const fn last_was_success(&self) -> bool {
144        matches!(self.last_result, Some(r) if r.is_success())
145    }
146}
147
148impl EventProvider for BiometricManager {
149    /// Yield a window-level `BiometricResult` event when a prompt outcome
150    /// was folded since the last pass (target = root; read the outcome via
151    /// `CallbackInfo::get_biometric_result` inside the callback).
152    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
153        if self.pending_event {
154            alloc::vec![SyntheticEvent::new(
155                EventType::BiometricResult,
156                CoreEventSource::User,
157                DomNodeId::ROOT,
158                timestamp,
159                EventData::None,
160            )]
161        } else {
162            Vec::new()
163        }
164    }
165}
166
167// ────────── Async result channel (platform backend → manager) ─────────
168//
169// The OS prompt's reply block / `AuthenticationCallback` fires on an
170// arbitrary thread with no handle to the live `BiometricManager` (it
171// lives inside the window's `LayoutWindow`). The backend parks each
172// result here; the layout pass drains it once per frame via
173// [`drain_biometric_results`] and applies the latest through
174// [`BiometricManager::set_last_result`]. Pure Rust — no platform
175// dependency (SUPER_PLAN_2 §0.5). Mirrors the geolocation manager's
176// async-fix channel.
177
178static PENDING_RESULTS: std::sync::Mutex<Vec<BiometricResult>> =
179    std::sync::Mutex::new(Vec::new());
180
181/// Park a biometric result delivered by a platform backend (in the dll).
182/// Thread-safe; recovers from a poisoned lock so one panicking applier
183/// can't wedge delivery forever.
184pub fn push_biometric_result(result: BiometricResult) {
185    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
186    q.push(result);
187}
188
189/// Drain every result parked by [`push_biometric_result`], in arrival
190/// order. Called once per layout pass; the caller applies them through
191/// [`BiometricManager::set_last_result`] (the last one wins).
192pub fn drain_biometric_results() -> Vec<BiometricResult> {
193    let mut q = PENDING_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
194    core::mem::take(&mut *q)
195}
196
197// ────────── Request channel (callback → platform backend) ─────────────
198//
199// The reverse direction: a callback (e.g. an unlock button's `on_click`)
200// calls `CallbackInfo::request_biometric_auth(prompt)`, which parks the
201// prompt here. The dll layout pass drains it via
202// [`drain_biometric_requests`] and dispatches each to the native backend
203// (`dll::desktop::extra::biometric::request`), which shows the OS prompt
204// and later parks the outcome back through [`push_biometric_result`].
205// Decoupling via a channel keeps the request callable from any callback
206// without threading the window's backend handle through `CallbackInfo`,
207// and keeps `azul-layout` platform-free (SUPER_PLAN_2 §0.5).
208
209static PENDING_REQUESTS: std::sync::Mutex<Vec<BiometricPrompt>> =
210    std::sync::Mutex::new(Vec::new());
211
212/// Queue a biometric-auth request from a callback. Picked up by the dll
213/// layout pass and dispatched to the native prompt. Thread-safe;
214/// poison-recovering.
215pub fn push_biometric_request(prompt: BiometricPrompt) {
216    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
217    q.push(prompt);
218}
219
220/// Drain every request queued by [`push_biometric_request`], in arrival
221/// order. Called once per layout pass; the dll dispatches each to the
222/// platform backend.
223pub fn drain_biometric_requests() -> Vec<BiometricPrompt> {
224    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
225    core::mem::take(&mut *q)
226}
227
228/// MWA-C-biometric: true while requests are parked in the channel but not
229/// yet dispatched.
230///
231/// The capability pump's arming check must count these —
232/// `has_pending_async` only sees `in_flight` (post-dispatch), so a prompt
233/// queued MID-pass (after the top-of-pass pump already ran) would otherwise
234/// wait for an unrelated event before ever being shown.
235pub fn has_queued_requests() -> bool {
236    PENDING_REQUESTS
237        .lock()
238        .map_or_else(|e| !e.into_inner().is_empty(), |q| !q.is_empty())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn manager_defaults_to_unavailable_and_no_result() {
247        let mgr = BiometricManager::new();
248        assert_eq!(mgr.availability(), BiometricKind::NotAvailable);
249        assert!(!mgr.is_available());
250        assert_eq!(mgr.last_result(), None);
251        assert!(!mgr.last_was_success());
252    }
253
254    #[test]
255    fn set_availability_returns_change_flag() {
256        let mut mgr = BiometricManager::new();
257        assert!(mgr.set_availability(BiometricKind::Face));
258        assert!(mgr.is_available());
259        assert_eq!(mgr.availability(), BiometricKind::Face);
260        // Same value again — no change.
261        assert!(!mgr.set_availability(BiometricKind::Face));
262        // Different value — change.
263        assert!(mgr.set_availability(BiometricKind::Fingerprint));
264    }
265
266    #[test]
267    fn set_last_result_returns_change_flag() {
268        let mut mgr = BiometricManager::new();
269        assert!(mgr.set_last_result(BiometricResult::Failed));
270        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
271        assert!(!mgr.last_was_success());
272        // Re-applying the same outcome is not a change.
273        assert!(!mgr.set_last_result(BiometricResult::Failed));
274        // A new outcome is a change, and Authenticated is a success.
275        assert!(mgr.set_last_result(BiometricResult::Authenticated));
276        assert!(mgr.last_was_success());
277    }
278
279    #[test]
280    fn passcode_fallback_counts_as_success() {
281        let mut mgr = BiometricManager::new();
282        mgr.set_last_result(BiometricResult::FellBackToPasscode);
283        assert!(mgr.last_was_success());
284        assert!(BiometricResult::FellBackToPasscode.is_success());
285        // Cancelled / Failed / Unavailable / Error are not successes.
286        for r in [
287            BiometricResult::Cancelled,
288            BiometricResult::Failed,
289            BiometricResult::Unavailable,
290            BiometricResult::Error,
291        ] {
292            assert!(!r.is_success(), "{r:?} must not be a success");
293        }
294    }
295
296    #[test]
297    fn async_results_round_trip_through_manager() {
298        // The channel is process-global; clear any residue first.
299        drop(drain_biometric_results());
300
301        push_biometric_result(BiometricResult::Failed);
302        push_biometric_result(BiometricResult::Authenticated); // last wins
303        let drained = drain_biometric_results();
304        assert_eq!(drained.len(), 2, "both parked results drain in order");
305        assert_eq!(drained[0], BiometricResult::Failed);
306        assert_eq!(drained[1], BiometricResult::Authenticated);
307
308        // Applying them reflects in last_result() — what the layout pass does.
309        let mut mgr = BiometricManager::new();
310        for r in &drained {
311            mgr.set_last_result(*r);
312        }
313        assert_eq!(
314            mgr.last_result(),
315            Some(BiometricResult::Authenticated),
316            "the last applied result wins"
317        );
318        assert!(mgr.last_was_success());
319
320        // A second drain is empty — the queue was taken, not copied.
321        assert!(drain_biometric_results().is_empty());
322    }
323
324    #[test]
325    fn requests_round_trip_through_channel() {
326        // Process-global; clear residue first.
327        drop(drain_biometric_requests());
328
329        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
330        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
331        let drained = drain_biometric_requests();
332        assert_eq!(drained.len(), 2, "both queued requests drain in order");
333        assert_eq!(drained[0].reason.as_str(), "Unlock A");
334        assert_eq!(drained[1].reason.as_str(), "Unlock B");
335
336        // A second drain is empty — the queue was taken, not copied.
337        assert!(drain_biometric_requests().is_empty());
338    }
339
340    #[test]
341    fn biometric_prompt_defaults_and_constructor() {
342        let d = BiometricPrompt::default();
343        assert!(!d.allow_device_credential);
344        assert_eq!(d.reason.as_str(), "");
345
346        let p = BiometricPrompt::new("Unlock your vault".into());
347        assert_eq!(p.reason.as_str(), "Unlock your vault");
348        assert_eq!(p.cancel_label.as_str(), "");
349        assert!(!p.allow_device_credential);
350    }
351}
352
353#[cfg(test)]
354mod pump_provider_tests {
355    use super::*;
356    use azul_core::task::{Instant, SystemTick};
357
358    fn ts() -> Instant {
359        Instant::Tick(SystemTick::new(0))
360    }
361
362    #[test]
363    fn in_flight_tracks_dispatch_and_completion() {
364        let mut mgr = BiometricManager::new();
365        assert!(!mgr.has_pending_async());
366        mgr.mark_requests_dispatched(2);
367        assert!(mgr.has_pending_async());
368        mgr.set_last_result(BiometricResult::Cancelled);
369        assert!(mgr.has_pending_async(), "one of two still outstanding");
370        mgr.set_last_result(BiometricResult::Authenticated);
371        assert!(!mgr.has_pending_async());
372        // saturates — an unsolicited result never underflows
373        mgr.set_last_result(BiometricResult::Failed);
374        assert!(!mgr.has_pending_async());
375    }
376
377    #[test]
378    fn every_completion_fires_an_event_even_identical_repeats() {
379        let mut mgr = BiometricManager::new();
380        assert!(mgr.get_pending_events(ts()).is_empty());
381        mgr.set_last_result(BiometricResult::Cancelled);
382        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
383        mgr.clear_pending_event();
384        assert!(mgr.get_pending_events(ts()).is_empty());
385        // identical outcome answering a FRESH prompt → fresh event
386        mgr.set_last_result(BiometricResult::Cancelled);
387        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
388        assert_eq!(
389            mgr.get_pending_events(ts())[0].event_type,
390            EventType::BiometricResult
391        );
392    }
393}