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}