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

            
27
use 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.
33
pub 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)]
38
pub 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

            
50
impl Default for BiometricManager {
51
2326
    fn default() -> Self {
52
2326
        Self {
53
2326
            last_result: None,
54
2326
            availability: BiometricKind::NotAvailable,
55
2326
        }
56
2326
    }
57
}
58

            
59
impl BiometricManager {
60
2326
    pub fn new() -> Self {
61
2326
        Self::default()
62
2326
    }
63

            
64
    /// Most recent auth outcome, or `None` until the first request
65
    /// resolves.
66
3
    pub fn last_result(&self) -> Option<BiometricResult> {
67
3
        self.last_result
68
3
    }
69

            
70
    /// Device capability probe (sync). `NotAvailable` until the backend
71
    /// reports otherwise.
72
2
    pub fn availability(&self) -> BiometricKind {
73
2
        self.availability
74
2
    }
75

            
76
    /// `true` if the device has a usable biometric sensor.
77
2
    pub fn is_available(&self) -> bool {
78
2
        self.availability.is_available()
79
2
    }
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
3
    pub fn set_availability(&mut self, kind: BiometricKind) -> bool {
85
3
        let changed = self.availability != kind;
86
3
        self.availability = kind;
87
3
        changed
88
3
    }
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
6
    pub fn set_last_result(&mut self, result: BiometricResult) -> bool {
95
6
        let changed = self.last_result != Some(result);
96
6
        self.last_result = Some(result);
97
6
        changed
98
6
    }
99

            
100
    /// `true` if the last attempt unlocked successfully (biometric match
101
    /// or OS passcode fallback). Convenience for the vault gate.
102
5
    pub fn last_was_success(&self) -> bool {
103
4
        matches!(self.last_result, Some(r) if r.is_success())
104
5
    }
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

            
118
static 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.
124
2
pub fn push_biometric_result(result: BiometricResult) {
125
2
    let mut q = PENDING_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
126
2
    q.push(result);
127
2
}
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).
132
3
pub fn drain_biometric_results() -> Vec<BiometricResult> {
133
3
    let mut q = PENDING_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
134
3
    core::mem::take(&mut *q)
135
3
}
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

            
149
static 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.
155
2
pub fn push_biometric_request(prompt: BiometricPrompt) {
156
2
    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(|e| e.into_inner());
157
2
    q.push(prompt);
158
2
}
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.
163
3
pub fn drain_biometric_requests() -> Vec<BiometricPrompt> {
164
3
    let mut q = PENDING_REQUESTS.lock().unwrap_or_else(|e| e.into_inner());
165
3
    core::mem::take(&mut *q)
166
3
}
167

            
168
#[cfg(test)]
169
mod tests {
170
    use super::*;
171

            
172
    #[test]
173
1
    fn manager_defaults_to_unavailable_and_no_result() {
174
1
        let mgr = BiometricManager::new();
175
1
        assert_eq!(mgr.availability(), BiometricKind::NotAvailable);
176
1
        assert!(!mgr.is_available());
177
1
        assert_eq!(mgr.last_result(), None);
178
1
        assert!(!mgr.last_was_success());
179
1
    }
180

            
181
    #[test]
182
1
    fn set_availability_returns_change_flag() {
183
1
        let mut mgr = BiometricManager::new();
184
1
        assert!(mgr.set_availability(BiometricKind::Face));
185
1
        assert!(mgr.is_available());
186
1
        assert_eq!(mgr.availability(), BiometricKind::Face);
187
        // Same value again — no change.
188
1
        assert!(!mgr.set_availability(BiometricKind::Face));
189
        // Different value — change.
190
1
        assert!(mgr.set_availability(BiometricKind::Fingerprint));
191
1
    }
192

            
193
    #[test]
194
1
    fn set_last_result_returns_change_flag() {
195
1
        let mut mgr = BiometricManager::new();
196
1
        assert!(mgr.set_last_result(BiometricResult::Failed));
197
1
        assert_eq!(mgr.last_result(), Some(BiometricResult::Failed));
198
1
        assert!(!mgr.last_was_success());
199
        // Re-applying the same outcome is not a change.
200
1
        assert!(!mgr.set_last_result(BiometricResult::Failed));
201
        // A new outcome is a change, and Authenticated is a success.
202
1
        assert!(mgr.set_last_result(BiometricResult::Authenticated));
203
1
        assert!(mgr.last_was_success());
204
1
    }
205

            
206
    #[test]
207
1
    fn passcode_fallback_counts_as_success() {
208
1
        let mut mgr = BiometricManager::new();
209
1
        mgr.set_last_result(BiometricResult::FellBackToPasscode);
210
1
        assert!(mgr.last_was_success());
211
1
        assert!(BiometricResult::FellBackToPasscode.is_success());
212
        // Cancelled / Failed / Unavailable / Error are not successes.
213
4
        for r in [
214
1
            BiometricResult::Cancelled,
215
1
            BiometricResult::Failed,
216
1
            BiometricResult::Unavailable,
217
1
            BiometricResult::Error,
218
        ] {
219
4
            assert!(!r.is_success(), "{:?} must not be a success", r);
220
        }
221
1
    }
222

            
223
    #[test]
224
1
    fn async_results_round_trip_through_manager() {
225
        // The channel is process-global; clear any residue first.
226
1
        let _ = drain_biometric_results();
227

            
228
1
        push_biometric_result(BiometricResult::Failed);
229
1
        push_biometric_result(BiometricResult::Authenticated); // last wins
230
1
        let drained = drain_biometric_results();
231
1
        assert_eq!(drained.len(), 2, "both parked results drain in order");
232
1
        assert_eq!(drained[0], BiometricResult::Failed);
233
1
        assert_eq!(drained[1], BiometricResult::Authenticated);
234

            
235
        // Applying them reflects in last_result() — what the layout pass does.
236
1
        let mut mgr = BiometricManager::new();
237
3
        for r in &drained {
238
2
            mgr.set_last_result(*r);
239
2
        }
240
1
        assert_eq!(
241
1
            mgr.last_result(),
242
            Some(BiometricResult::Authenticated),
243
            "the last applied result wins"
244
        );
245
1
        assert!(mgr.last_was_success());
246

            
247
        // A second drain is empty — the queue was taken, not copied.
248
1
        assert!(drain_biometric_results().is_empty());
249
1
    }
250

            
251
    #[test]
252
1
    fn requests_round_trip_through_channel() {
253
        // Process-global; clear residue first.
254
1
        let _ = drain_biometric_requests();
255

            
256
1
        push_biometric_request(BiometricPrompt::new("Unlock A".into()));
257
1
        push_biometric_request(BiometricPrompt::new("Unlock B".into()));
258
1
        let drained = drain_biometric_requests();
259
1
        assert_eq!(drained.len(), 2, "both queued requests drain in order");
260
1
        assert_eq!(drained[0].reason.as_str(), "Unlock A");
261
1
        assert_eq!(drained[1].reason.as_str(), "Unlock B");
262

            
263
        // A second drain is empty — the queue was taken, not copied.
264
1
        assert!(drain_biometric_requests().is_empty());
265
1
    }
266

            
267
    #[test]
268
1
    fn biometric_prompt_defaults_and_constructor() {
269
1
        let d = BiometricPrompt::default();
270
1
        assert!(!d.allow_device_credential);
271
1
        assert_eq!(d.reason.as_str(), "");
272

            
273
1
        let p = BiometricPrompt::new("Unlock your vault".into());
274
1
        assert_eq!(p.reason.as_str(), "Unlock your vault");
275
1
        assert_eq!(p.cancel_label.as_str(), "");
276
1
        assert!(!p.allow_device_credential);
277
1
    }
278
}