Skip to main content

azul_layout/managers/
permission.rs

1//! Permission manager — the cross-platform piece of the "permission-as-DOM"
2//! architecture (`SUPER_PLAN_2.md` §1.5 and `scripts/research/08_permission_dom_nodes.md`).
3//!
4//! Stores per-capability state + a refcount keyed on bearing DOM nodes. Three
5//! callers drive it:
6//!
7//! - The **layout pass** scans the styled DOM for permission-bearing
8//!   NodeTypes (`GeolocationProbe`, `CameraPreview`, `SensorProbe`, etc.) and
9//!   calls `subscribe` / `release` to maintain the refcount. The diff
10//!   between consecutive layouts yields the [`PermissionDiffEvent`]s the
11//!   platform backend translates into native subscribe/release operations.
12//!
13//! - The **platform backend** (`dll/src/desktop/extra/permission/<plat>.rs`)
14//!   observes the diff events and issues the matching native call
15//!   (`AVCaptureDevice.requestAccess` on iOS, `ActivityCompat.requestPermissions`
16//!   on Android, etc.). When the OS callback fires it calls `set_status`,
17//!   which is mirrored back into callback land via the `CallbackInfo`
18//!   accessor `get_permission_status`.
19//!
20//! - **Callbacks** read `get_status(...)` synchronously to decide whether
21//!   to mount a permission-bearing node or show a fallback (the
22//!   "user-gesture-first" pattern in the research brief §8.3).
23//!
24//! The manager has no platform dependencies and is `no_std`-friendly (uses
25//! `alloc::collections::BTreeMap` + `alloc::vec::Vec`).
26
27use alloc::collections::btree_map::BTreeMap;
28use alloc::vec::Vec;
29
30use azul_core::dom::DomNodeId;
31
32/// One closed enum covering every capability the framework can request.
33///
34/// The variant set deliberately omits fields like `facing` / `accuracy` /
35/// `mode` from the research brief — those parameters belong on the bearing
36/// `NodeType` (e.g. `NodeType::CameraPreview(CameraSource::Front)`) so they
37/// can change between layout passes without forcing a re-prompt. The
38/// `Reconfigure` diff event carries the new params when a node mutates.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40#[repr(C)]
41pub enum Capability {
42    /// Camera access (front or back, declared per node).
43    Camera,
44    /// Microphone access. iOS gates this separately from camera.
45    Microphone,
46    /// Entire-screen or per-window capture.
47    ScreenCapture,
48    /// Geolocation (precise vs approximate is per-node, not per-capability).
49    Geolocation,
50    /// Background geolocation. A separate iOS / Android permission gate.
51    GeolocationBackground,
52    /// FaceID / TouchID / Hello / `BiometricPrompt`.
53    Biometric,
54    /// Motion sensor data (accelerometer + gyro + magnetometer).
55    Motion,
56    /// PhotoKit / MediaStore read.
57    PhotoLibrary,
58    /// PhotoKit add-only / MediaStore write.
59    PhotoLibraryWrite,
60    /// Contacts list.
61    Contacts,
62    /// Calendar entries.
63    Calendars,
64    /// Reminders (iOS only — Android collapses into Calendars).
65    Reminders,
66    /// Push / local notification scheduling.
67    Notifications,
68    /// Bluetooth foreground.
69    Bluetooth,
70    /// Bluetooth background. Separate iOS Info.plist key + Android permission.
71    BluetoothBackground,
72    /// Nearby Wi-Fi (Android 13+).
73    NearbyWifi,
74    /// Local network multicast (iOS 14+).
75    LocalNetwork,
76    /// iOS App Tracking Transparency (`IDFA` consent, iOS 14.5+).
77    AppTrackingTransparency,
78}
79
80/// Quality of a granted permission. Matches research/08 §2's quality split.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
82#[repr(C)]
83pub enum PermissionQuality {
84    /// Full: precise location, full photo library, etc.
85    Full,
86    /// Reduced: approximate location, "Selected Photos" partial access, etc.
87    Reduced,
88}
89
90/// State machine the manager tracks per-capability.
91///
92/// The five canonical states (`NotDetermined` / `Requested` / `Granted` /
93/// `Denied` / `Restricted`) cover what every supported platform reports.
94/// `EphemeralGranted` is the iOS 14+ "Allow Once" / Android 11+ one-time grant
95/// — semantically a Granted that the OS will reset to `NotDetermined` at the
96/// next activity launch.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
98#[repr(C, u8)]
99pub enum PermissionState {
100    /// Initial — no prompt has been shown.
101    NotDetermined,
102    /// OS prompt is currently visible / in-flight.
103    Requested,
104    /// User granted access.
105    Granted {
106        quality: PermissionQuality,
107    },
108    /// User denied access (with or without "don't ask again").
109    Denied,
110    /// MDM / parental controls / kiosk policy blocks the prompt entirely.
111    Restricted,
112    /// iOS "Allow Once" / Android one-time. Reverts on next app launch.
113    EphemeralGranted {
114        until_app_close: bool,
115    },
116}
117
118impl PermissionState {
119    /// `true` if the capability is currently usable, regardless of quality.
120    pub fn is_granted(self) -> bool {
121        matches!(
122            self,
123            PermissionState::Granted { .. } | PermissionState::EphemeralGranted { .. }
124        )
125    }
126
127    /// `true` if a re-prompt could plausibly flip this to `Granted`.
128    pub fn could_re_prompt(self) -> bool {
129        matches!(self, PermissionState::NotDetermined)
130    }
131}
132
133/// Diff event emitted at the end of each layout pass for the platform
134/// backend to translate into native subscribe / release / reconfigure calls.
135///
136/// `Subscribe` fires the first time a capability's refcount transitions from
137/// zero to one (i.e. the first permission-bearing node of its kind appears).
138/// `Release` fires when the refcount drops back to zero. `Reconfigure` is
139/// reserved for in-place parameter changes (e.g. camera-facing front → back)
140/// once `CameraPreview` lands as a NodeType — kept in the enum so platform
141/// backends can ignore it cleanly until then.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[repr(C, u8)]
144pub enum PermissionDiffEvent {
145    /// First appearance of `capability` in the layout. Refcount went 0 → 1.
146    Subscribe {
147        capability: Capability,
148        node_id: DomNodeId,
149    },
150    /// Last bearing node left the layout. Refcount went 1 → 0.
151    Release {
152        capability: Capability,
153    },
154    /// Reserved for future use — currently never emitted. The diff path will
155    /// fire it once `CameraPreview` etc. land with parameter fields.
156    Reconfigure {
157        capability: Capability,
158    },
159}
160
161/// Per-capability state held across frames.
162///
163/// `refcount` is the number of distinct DOM nodes currently in the layout
164/// that subscribed to this capability. `last_subscriber` is the node that
165/// caused the most recent 0 → 1 transition; the platform backend uses it
166/// to anchor permission-related events back to a node (so an
167/// `On::CameraPermissionDenied` callback fires on the right `CameraPreview`).
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct CapabilityEntry {
170    pub state: PermissionState,
171    pub refcount: u32,
172    pub last_subscriber: Option<DomNodeId>,
173}
174
175impl CapabilityEntry {
176    fn new() -> Self {
177        Self {
178            state: PermissionState::NotDetermined,
179            refcount: 0,
180            last_subscriber: None,
181        }
182    }
183}
184
185/// Cross-platform permission manager.
186///
187/// One per `App` (capabilities live at process scope, not per-window — a
188/// camera session backing two windows multiplexes via a single capture
189/// stream; cf. research/08 §8.6). `LayoutWindow` holds a borrow / `Arc`
190/// reference, not an owned copy.
191#[derive(Debug, Clone, PartialEq, Eq, Default)]
192pub struct PermissionManager {
193    /// Latest known state + refcount per capability.
194    pub statuses: BTreeMap<Capability, CapabilityEntry>,
195    /// Diff events emitted since the last call to `take_pending_events`.
196    ///
197    /// Held as a queue so the platform backend can drain it once per frame
198    /// instead of receiving callbacks during the layout pass itself (the
199    /// layout pass is on a hot path that should not block on FFI).
200    pending_events: Vec<PermissionDiffEvent>,
201}
202
203impl PermissionManager {
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    /// Read the most recently observed state for `capability`.
209    pub fn get_status(&self, capability: Capability) -> PermissionState {
210        self.statuses
211            .get(&capability)
212            .map(|e| e.state)
213            .unwrap_or(PermissionState::NotDetermined)
214    }
215
216    /// Record that `node_id` now needs `capability`. The first subscriber
217    /// (refcount 0 → 1) enqueues a `Subscribe` event for the platform layer
218    /// to translate into a native prompt.
219    pub fn subscribe(&mut self, capability: Capability, node_id: DomNodeId) {
220        let entry = self
221            .statuses
222            .entry(capability)
223            .or_insert_with(CapabilityEntry::new);
224        entry.last_subscriber = Some(node_id);
225        entry.refcount = entry.refcount.saturating_add(1);
226        if entry.refcount == 1 {
227            self.pending_events.push(PermissionDiffEvent::Subscribe {
228                capability,
229                node_id,
230            });
231        }
232    }
233
234    /// Drop one subscription. The last release (refcount 1 → 0) enqueues a
235    /// `Release` event so the platform backend can tear the session down.
236    pub fn release(&mut self, capability: Capability) {
237        let Some(entry) = self.statuses.get_mut(&capability) else {
238            return;
239        };
240        if entry.refcount == 0 {
241            return;
242        }
243        entry.refcount -= 1;
244        if entry.refcount == 0 {
245            entry.last_subscriber = None;
246            self.pending_events
247                .push(PermissionDiffEvent::Release { capability });
248        }
249    }
250
251    /// Force `capability`'s refcount down to zero. Used by `recheck_all` when
252    /// the OS revokes a permission out from under us — we have to tear down
253    /// the subscription regardless of how many DOM nodes still reference it.
254    pub fn force_release(&mut self, capability: Capability) {
255        let Some(entry) = self.statuses.get_mut(&capability) else {
256            return;
257        };
258        if entry.refcount == 0 {
259            return;
260        }
261        entry.refcount = 0;
262        entry.last_subscriber = None;
263        self.pending_events
264            .push(PermissionDiffEvent::Release { capability });
265    }
266
267    /// Platform backend writes the OS-observed state back into the manager.
268    ///
269    /// Returns true if the state actually changed — the caller can use this
270    /// signal to mark the window dirty for relayout (so a permission-aware
271    /// callback gets a chance to render the new state).
272    pub fn set_status(&mut self, capability: Capability, state: PermissionState) -> bool {
273        let entry = self
274            .statuses
275            .entry(capability)
276            .or_insert_with(CapabilityEntry::new);
277        if entry.state == state {
278            return false;
279        }
280        entry.state = state;
281        true
282    }
283
284    /// Drain queued diff events. Platform backend calls this once per frame.
285    pub fn take_pending_events(&mut self) -> Vec<PermissionDiffEvent> {
286        core::mem::take(&mut self.pending_events)
287    }
288
289    /// Refcount snapshot — primarily for diagnostics and tests.
290    pub fn refcount(&self, capability: Capability) -> u32 {
291        self.statuses
292            .get(&capability)
293            .map(|e| e.refcount)
294            .unwrap_or(0)
295    }
296
297    /// Pre-compute the next-frame refcount map from a closure that yields
298    /// `(capability, node_id)` pairs for every permission-bearing node in
299    /// the current styled DOM. Then diff against the existing refcounts and
300    /// enqueue the matching Subscribe / Release events.
301    ///
302    /// This is the entry point the layout pass calls. It exists as a closure
303    /// rather than a direct `StyledDom` walker because `StyledDom` lives in
304    /// `azul_core::styled_dom` and would otherwise force a (tiny) cycle.
305    pub fn diff_layout<F>(&mut self, mut for_each_bearing_node: F)
306    where
307        F: FnMut(&mut dyn FnMut(Capability, DomNodeId)),
308    {
309        // 1. Drain the new layout into (capability → (count, first_node)).
310        let mut next: BTreeMap<Capability, (u32, Option<DomNodeId>)> = BTreeMap::new();
311        for_each_bearing_node(&mut |cap, node| {
312            let slot = next.entry(cap).or_insert((0, None));
313            slot.0 = slot.0.saturating_add(1);
314            if slot.1.is_none() {
315                slot.1 = Some(node);
316            }
317        });
318
319        // 2. Compute the new state map from the old one + the next layout.
320        // Iterate every capability we know about plus any new ones.
321        let mut all_caps: Vec<Capability> = self.statuses.keys().copied().collect();
322        for cap in next.keys() {
323            if !all_caps.contains(cap) {
324                all_caps.push(*cap);
325            }
326        }
327
328        for cap in all_caps {
329            let (new_count, first_node) = next.get(&cap).copied().unwrap_or((0, None));
330            let entry = self
331                .statuses
332                .entry(cap)
333                .or_insert_with(CapabilityEntry::new);
334            let old_count = entry.refcount;
335            entry.refcount = new_count;
336            if new_count == 0 && old_count > 0 {
337                entry.last_subscriber = None;
338                self.pending_events
339                    .push(PermissionDiffEvent::Release { capability: cap });
340            } else if new_count > 0 && old_count == 0 {
341                let node = first_node.unwrap_or(DomNodeId::ROOT);
342                entry.last_subscriber = first_node;
343                self.pending_events.push(PermissionDiffEvent::Subscribe {
344                    capability: cap,
345                    node_id: node,
346                });
347            }
348        }
349    }
350}
351
352// ────────── Async result channel (platform backend → manager) ─────────
353//
354// When a `Subscribe` fires an OS prompt, the result arrives later on an
355// arbitrary thread (an iOS completion handler / Android
356// `onRequestPermissionsResult`) where there's no handle to the live
357// `PermissionManager` (it lives inside the window's `LayoutWindow`). The
358// platform backend parks the resolved state here; the layout pass drains
359// it once per frame via [`drain_async_results`] and applies each through
360// [`PermissionManager::set_status`]. Pure Rust — no platform dependency,
361// so it satisfies SUPER_PLAN_2 §0.5's "no platform deps in azul-layout".
362
363static ASYNC_RESULTS: std::sync::Mutex<Vec<(Capability, PermissionState)>> =
364    std::sync::Mutex::new(Vec::new());
365
366/// Park an async permission result. Called by a platform backend (in the
367/// dll) when an OS prompt resolves. Thread-safe; recovers from a poisoned
368/// lock so one panicking applier can't wedge delivery forever.
369pub fn push_async_result(capability: Capability, state: PermissionState) {
370    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
371    q.push((capability, state));
372}
373
374/// Drain everything parked by [`push_async_result`], in arrival order.
375/// Called once per layout pass; the caller applies each result through
376/// [`PermissionManager::set_status`] and relayouts if any changed.
377pub fn drain_async_results() -> Vec<(Capability, PermissionState)> {
378    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(|e| e.into_inner());
379    core::mem::take(&mut *q)
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use azul_core::dom::{DomId, NodeId};
386
387    fn node(idx: usize) -> DomNodeId {
388        DomNodeId {
389            dom: DomId::ROOT_ID,
390            node: NodeId::from_usize(idx).into(),
391        }
392    }
393
394    #[test]
395    fn subscribe_release_round_trip_emits_paired_events() {
396        let mut mgr = PermissionManager::new();
397        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::NotDetermined);
398        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
399
400        mgr.subscribe(Capability::Geolocation, node(1));
401        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
402        let events = mgr.take_pending_events();
403        assert_eq!(events.len(), 1);
404        assert!(matches!(
405            events[0],
406            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
407        ));
408
409        mgr.release(Capability::Geolocation);
410        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
411        let events = mgr.take_pending_events();
412        assert_eq!(events.len(), 1);
413        assert!(matches!(
414            events[0],
415            PermissionDiffEvent::Release { capability: Capability::Geolocation }
416        ));
417    }
418
419    #[test]
420    fn second_subscriber_does_not_re_emit_subscribe() {
421        let mut mgr = PermissionManager::new();
422        mgr.subscribe(Capability::Camera, node(1));
423        mgr.subscribe(Capability::Camera, node(2));
424        assert_eq!(mgr.refcount(Capability::Camera), 2);
425        let events = mgr.take_pending_events();
426        // Exactly one Subscribe should have been emitted across both subscribes.
427        assert_eq!(events.len(), 1);
428    }
429
430    #[test]
431    fn release_only_after_last_subscriber_drops() {
432        let mut mgr = PermissionManager::new();
433        mgr.subscribe(Capability::Microphone, node(1));
434        mgr.subscribe(Capability::Microphone, node(2));
435        // Drain the initial Subscribe so the assertion below isolates Release.
436        let _ = mgr.take_pending_events();
437
438        mgr.release(Capability::Microphone);
439        assert_eq!(mgr.refcount(Capability::Microphone), 1);
440        assert!(mgr.take_pending_events().is_empty());
441
442        mgr.release(Capability::Microphone);
443        assert_eq!(mgr.refcount(Capability::Microphone), 0);
444        let events = mgr.take_pending_events();
445        assert_eq!(events.len(), 1);
446        assert!(matches!(
447            events[0],
448            PermissionDiffEvent::Release { capability: Capability::Microphone }
449        ));
450    }
451
452    #[test]
453    fn force_release_drops_refcount_and_emits_event() {
454        let mut mgr = PermissionManager::new();
455        mgr.subscribe(Capability::Camera, node(1));
456        mgr.subscribe(Capability::Camera, node(2));
457        let _ = mgr.take_pending_events();
458
459        mgr.force_release(Capability::Camera);
460        assert_eq!(mgr.refcount(Capability::Camera), 0);
461        let events = mgr.take_pending_events();
462        assert_eq!(events.len(), 1);
463        assert!(matches!(
464            events[0],
465            PermissionDiffEvent::Release { capability: Capability::Camera }
466        ));
467    }
468
469    #[test]
470    fn set_status_returns_change_flag() {
471        let mut mgr = PermissionManager::new();
472        assert!(mgr.set_status(Capability::Camera, PermissionState::Requested));
473        assert!(!mgr.set_status(Capability::Camera, PermissionState::Requested));
474        assert!(mgr.set_status(
475            Capability::Camera,
476            PermissionState::Granted { quality: PermissionQuality::Full }
477        ));
478        assert!(mgr.get_status(Capability::Camera).is_granted());
479    }
480
481    #[test]
482    fn diff_layout_picks_up_appearing_node_and_releases_it_next_frame() {
483        let mut mgr = PermissionManager::new();
484
485        // Frame 1: GeolocationProbe present.
486        mgr.diff_layout(|emit| {
487            emit(Capability::Geolocation, node(7));
488        });
489        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
490        let events = mgr.take_pending_events();
491        assert_eq!(events.len(), 1);
492        assert!(matches!(
493            events[0],
494            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
495        ));
496
497        // Frame 2: probe removed.
498        mgr.diff_layout(|_emit| { /* no bearing nodes this frame */ });
499        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
500        let events = mgr.take_pending_events();
501        assert_eq!(events.len(), 1);
502        assert!(matches!(
503            events[0],
504            PermissionDiffEvent::Release { capability: Capability::Geolocation }
505        ));
506    }
507
508    #[test]
509    fn diff_layout_re_emits_subscribe_after_release_cycle() {
510        let mut mgr = PermissionManager::new();
511
512        mgr.diff_layout(|emit| emit(Capability::Camera, node(1)));
513        let _ = mgr.take_pending_events();
514
515        mgr.diff_layout(|_emit| {});
516        let _ = mgr.take_pending_events();
517
518        // Same capability reappears — must emit Subscribe again because the
519        // platform tore the session down on the prior Release.
520        mgr.diff_layout(|emit| emit(Capability::Camera, node(2)));
521        let events = mgr.take_pending_events();
522        assert_eq!(events.len(), 1);
523        assert!(matches!(
524            events[0],
525            PermissionDiffEvent::Subscribe { capability: Capability::Camera, .. }
526        ));
527    }
528
529    #[test]
530    fn async_results_round_trip_through_manager() {
531        // The channel is a process-global; clear anything a prior test or
532        // ordering left behind so this test is self-contained.
533        let _ = drain_async_results();
534
535        push_async_result(
536            Capability::Camera,
537            PermissionState::Granted {
538                quality: PermissionQuality::Full,
539            },
540        );
541        push_async_result(Capability::Geolocation, PermissionState::Denied);
542
543        let drained = drain_async_results();
544        assert_eq!(drained.len(), 2, "both parked results drain in order");
545        // Arrival order preserved.
546        assert_eq!(drained[0].0, Capability::Camera);
547        assert_eq!(drained[1].0, Capability::Geolocation);
548
549        // Applying them through the manager reflects in get_status — this is
550        // exactly what the dll layout pass does each frame.
551        let mut mgr = PermissionManager::new();
552        for (cap, state) in drained {
553            mgr.set_status(cap, state);
554        }
555        assert!(mgr.get_status(Capability::Camera).is_granted());
556        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::Denied);
557
558        // A second drain is empty — the queue was taken, not copied.
559        assert!(drain_async_results().is_empty());
560    }
561}