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}