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;
31use azul_core::events::{
32    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
33};
34use azul_core::task::Instant;
35
36/// One closed enum covering every capability the framework can request.
37///
38/// The variant set deliberately omits fields like `facing` / `accuracy` /
39/// `mode` from the research brief — those parameters belong on the bearing
40/// `NodeType` (e.g. `NodeType::CameraPreview(CameraSource::Front)`) so they
41/// can change between layout passes without forcing a re-prompt. The
42/// `Reconfigure` diff event carries the new params when a node mutates.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44#[repr(C)]
45pub enum Capability {
46    /// Camera access (front or back, declared per node).
47    Camera,
48    /// Microphone access. iOS gates this separately from camera.
49    Microphone,
50    /// Entire-screen or per-window capture.
51    ScreenCapture,
52    /// Geolocation (precise vs approximate is per-node, not per-capability).
53    Geolocation,
54    /// Background geolocation. A separate iOS / Android permission gate.
55    GeolocationBackground,
56    /// `FaceID` / `TouchID` / Hello / `BiometricPrompt`.
57    Biometric,
58    /// Motion sensor data (accelerometer + gyro + magnetometer).
59    Motion,
60    /// `PhotoKit` / `MediaStore` read.
61    PhotoLibrary,
62    /// `PhotoKit` add-only / `MediaStore` write.
63    PhotoLibraryWrite,
64    /// Contacts list.
65    Contacts,
66    /// Calendar entries.
67    Calendars,
68    /// Reminders (iOS only — Android collapses into Calendars).
69    Reminders,
70    /// Push / local notification scheduling.
71    Notifications,
72    /// Bluetooth foreground.
73    Bluetooth,
74    /// Bluetooth background. Separate iOS Info.plist key + Android permission.
75    BluetoothBackground,
76    /// Nearby Wi-Fi (Android 13+).
77    NearbyWifi,
78    /// Local network multicast (iOS 14+).
79    LocalNetwork,
80    /// iOS App Tracking Transparency (`IDFA` consent, iOS 14.5+).
81    AppTrackingTransparency,
82}
83
84/// Quality of a granted permission. Matches research/08 §2's quality split.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
86#[repr(C)]
87pub enum PermissionQuality {
88    /// Full: precise location, full photo library, etc.
89    Full,
90    /// Reduced: approximate location, "Selected Photos" partial access, etc.
91    Reduced,
92}
93
94/// State machine the manager tracks per-capability.
95///
96/// The five canonical states (`NotDetermined` / `Requested` / `Granted` /
97/// `Denied` / `Restricted`) cover what every supported platform reports.
98/// `EphemeralGranted` is the iOS 14+ "Allow Once" / Android 11+ one-time grant
99/// — semantically a Granted that the OS will reset to `NotDetermined` at the
100/// next activity launch.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102#[repr(C, u8)]
103pub enum PermissionState {
104    /// Initial — no prompt has been shown.
105    NotDetermined,
106    /// OS prompt is currently visible / in-flight.
107    Requested,
108    /// User granted access.
109    Granted(PermissionQuality),
110    /// User denied access (with or without "don't ask again").
111    Denied,
112    /// MDM / parental controls / kiosk policy blocks the prompt entirely.
113    Restricted,
114    /// iOS "Allow Once" / Android one-time. Reverts on next app launch.
115    EphemeralGranted(bool),
116}
117
118impl PermissionState {
119    /// `true` if the capability is currently usable, regardless of quality.
120    #[must_use] pub const fn is_granted(self) -> bool {
121        matches!(
122            self,
123            Self::Granted(..) | Self::EphemeralGranted(..)
124        )
125    }
126
127    /// `true` if a re-prompt could plausibly flip this to `Granted`.
128    #[must_use] pub const fn could_re_prompt(self) -> bool {
129        matches!(self, Self::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(Copy, 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(Copy, 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    const 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    /// State flips folded since the last event pass, with the capability's
202    /// most recent subscriber node (MWA-A1b). Read by the `EventProvider`
203    /// impl to synthesize targeted `PermissionChanged` events; cleared by
204    /// [`clear_pending_changed`](Self::clear_pending_changed) after dispatch.
205    pending_changed: Vec<(Capability, Option<DomNodeId>)>,
206}
207
208impl EventProvider for PermissionManager {
209    /// Yield one `PermissionChanged` event per state flip folded since the
210    /// last pass — targeted at the capability's most recent subscriber node
211    /// when known (so a probe node's Hover callback fires), else the root
212    /// (window-level filters match either way).
213    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
214        self.pending_changed
215            .iter()
216            .map(|(_capability, node)| {
217                SyntheticEvent::new(
218                    EventType::PermissionChanged,
219                    CoreEventSource::User,
220                    node.unwrap_or(DomNodeId::ROOT),
221                    timestamp.clone(),
222                    EventData::None,
223                )
224            })
225            .collect()
226    }
227}
228
229impl PermissionManager {
230    #[must_use] pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Read the most recently observed state for `capability`.
235    #[must_use] pub fn get_status(&self, capability: Capability) -> PermissionState {
236        self.statuses
237            .get(&capability)
238            .map_or(PermissionState::NotDetermined, |e| e.state)
239    }
240
241    /// Record that `node_id` now needs `capability`. The first subscriber
242    /// (refcount 0 → 1) enqueues a `Subscribe` event for the platform layer
243    /// to translate into a native prompt.
244    pub fn subscribe(&mut self, capability: Capability, node_id: DomNodeId) {
245        let entry = self
246            .statuses
247            .entry(capability)
248            .or_insert_with(CapabilityEntry::new);
249        entry.last_subscriber = Some(node_id);
250        entry.refcount = entry.refcount.saturating_add(1);
251        if entry.refcount == 1 {
252            self.pending_events.push(PermissionDiffEvent::Subscribe {
253                capability,
254                node_id,
255            });
256        }
257    }
258
259    /// Drop one subscription. The last release (refcount 1 → 0) enqueues a
260    /// `Release` event so the platform backend can tear the session down.
261    pub fn release(&mut self, capability: Capability) {
262        let Some(entry) = self.statuses.get_mut(&capability) else {
263            return;
264        };
265        if entry.refcount == 0 {
266            return;
267        }
268        entry.refcount -= 1;
269        if entry.refcount == 0 {
270            entry.last_subscriber = None;
271            self.pending_events
272                .push(PermissionDiffEvent::Release { capability });
273        }
274    }
275
276    /// Force `capability`'s refcount down to zero. Used by `recheck_all` when
277    /// the OS revokes a permission out from under us — we have to tear down
278    /// the subscription regardless of how many DOM nodes still reference it.
279    pub fn force_release(&mut self, capability: Capability) {
280        let Some(entry) = self.statuses.get_mut(&capability) else {
281            return;
282        };
283        if entry.refcount == 0 {
284            return;
285        }
286        entry.refcount = 0;
287        entry.last_subscriber = None;
288        self.pending_events
289            .push(PermissionDiffEvent::Release { capability });
290    }
291
292    /// Platform backend writes the OS-observed state back into the manager.
293    ///
294    /// Returns true if the state actually changed — the caller can use this
295    /// signal to mark the window dirty for relayout (so a permission-aware
296    /// callback gets a chance to render the new state).
297    pub fn set_status(&mut self, capability: Capability, state: PermissionState) -> bool {
298        let entry = self
299            .statuses
300            .entry(capability)
301            .or_insert_with(CapabilityEntry::new);
302        if entry.state == state {
303            return false;
304        }
305        entry.state = state;
306        // MWA-A1b: remember the flip so the EventProvider can synthesize a
307        // PermissionChanged event, targeted at the subscriber node when known.
308        self.pending_changed.push((capability, entry.last_subscriber));
309        true
310    }
311
312    /// Clear the pending state-flip queue. The dll calls this after the
313    /// event pass has collected the `PermissionChanged` events.
314    pub fn clear_pending_changed(&mut self) {
315        self.pending_changed.clear();
316    }
317
318    /// `true` while any capability sits in [`PermissionState::Requested`]
319    /// (an OS prompt is in flight and its outcome will arrive through the
320    /// async channel) — the capability pump keeps its timer armed so the
321    /// outcome reaches callbacks even in an otherwise idle app (MWA-A1b
322    /// arming signal).
323    #[must_use] pub fn has_pending_async(&self) -> bool {
324        self.statuses
325            .values()
326            .any(|e| e.state == PermissionState::Requested)
327    }
328
329    /// Drain queued diff events. Platform backend calls this once per frame.
330    pub fn take_pending_events(&mut self) -> Vec<PermissionDiffEvent> {
331        core::mem::take(&mut self.pending_events)
332    }
333
334    /// Refcount snapshot — primarily for diagnostics and tests.
335    #[must_use] pub fn refcount(&self, capability: Capability) -> u32 {
336        self.statuses
337            .get(&capability)
338            .map_or(0, |e| e.refcount)
339    }
340
341    /// Pre-compute the next-frame refcount map from a closure that yields
342    /// `(capability, node_id)` pairs for every permission-bearing node in
343    /// the current styled DOM. Then diff against the existing refcounts and
344    /// enqueue the matching Subscribe / Release events.
345    ///
346    /// This is the entry point the layout pass calls. It exists as a closure
347    /// rather than a direct `StyledDom` walker because `StyledDom` lives in
348    /// `azul_core::styled_dom` and would otherwise force a (tiny) cycle.
349    pub fn diff_layout<F>(&mut self, mut for_each_bearing_node: F)
350    where
351        F: FnMut(&mut dyn FnMut(Capability, DomNodeId)),
352    {
353        // 1. Drain the new layout into (capability → (count, first_node)).
354        let mut next: BTreeMap<Capability, (u32, Option<DomNodeId>)> = BTreeMap::new();
355        for_each_bearing_node(&mut |cap, node| {
356            let slot = next.entry(cap).or_insert((0, None));
357            slot.0 = slot.0.saturating_add(1);
358            if slot.1.is_none() {
359                slot.1 = Some(node);
360            }
361        });
362
363        // 2. Compute the new state map from the old one + the next layout.
364        // Iterate every capability we know about plus any new ones.
365        let mut all_caps: Vec<Capability> = self.statuses.keys().copied().collect();
366        for cap in next.keys() {
367            if !all_caps.contains(cap) {
368                all_caps.push(*cap);
369            }
370        }
371
372        for cap in all_caps {
373            let (new_count, first_node) = next.get(&cap).copied().unwrap_or((0, None));
374            let entry = self
375                .statuses
376                .entry(cap)
377                .or_insert_with(CapabilityEntry::new);
378            let old_count = entry.refcount;
379            entry.refcount = new_count;
380            if new_count == 0 && old_count > 0 {
381                entry.last_subscriber = None;
382                self.pending_events
383                    .push(PermissionDiffEvent::Release { capability: cap });
384            } else if new_count > 0 && old_count == 0 {
385                let node = first_node.unwrap_or(DomNodeId::ROOT);
386                entry.last_subscriber = first_node;
387                self.pending_events.push(PermissionDiffEvent::Subscribe {
388                    capability: cap,
389                    node_id: node,
390                });
391            }
392        }
393    }
394}
395
396// ────────── Async result channel (platform backend → manager) ─────────
397//
398// When a `Subscribe` fires an OS prompt, the result arrives later on an
399// arbitrary thread (an iOS completion handler / Android
400// `onRequestPermissionsResult`) where there's no handle to the live
401// `PermissionManager` (it lives inside the window's `LayoutWindow`). The
402// platform backend parks the resolved state here; the layout pass drains
403// it once per frame via [`drain_async_results`] and applies each through
404// [`PermissionManager::set_status`]. Pure Rust — no platform dependency,
405// so it satisfies SUPER_PLAN_2 §0.5's "no platform deps in azul-layout".
406
407static ASYNC_RESULTS: std::sync::Mutex<Vec<(Capability, PermissionState)>> =
408    std::sync::Mutex::new(Vec::new());
409
410/// Park an async permission result. Called by a platform backend (in the
411/// dll) when an OS prompt resolves. Thread-safe; recovers from a poisoned
412/// lock so one panicking applier can't wedge delivery forever.
413pub fn push_async_result(capability: Capability, state: PermissionState) {
414    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
415    q.push((capability, state));
416}
417
418/// Drain everything parked by [`push_async_result`], in arrival order.
419/// Called once per layout pass; the caller applies each result through
420/// [`PermissionManager::set_status`] and relayouts if any changed.
421pub fn drain_async_results() -> Vec<(Capability, PermissionState)> {
422    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
423    core::mem::take(&mut *q)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use azul_core::dom::{DomId, NodeId};
430
431    fn node(idx: usize) -> DomNodeId {
432        DomNodeId {
433            dom: DomId::ROOT_ID,
434            node: NodeId::from_usize(idx).into(),
435        }
436    }
437
438    #[test]
439    fn subscribe_release_round_trip_emits_paired_events() {
440        let mut mgr = PermissionManager::new();
441        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::NotDetermined);
442        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
443
444        mgr.subscribe(Capability::Geolocation, node(1));
445        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
446        let events = mgr.take_pending_events();
447        assert_eq!(events.len(), 1);
448        assert!(matches!(
449            events[0],
450            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
451        ));
452
453        mgr.release(Capability::Geolocation);
454        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
455        let events = mgr.take_pending_events();
456        assert_eq!(events.len(), 1);
457        assert!(matches!(
458            events[0],
459            PermissionDiffEvent::Release { capability: Capability::Geolocation }
460        ));
461    }
462
463    #[test]
464    fn second_subscriber_does_not_re_emit_subscribe() {
465        let mut mgr = PermissionManager::new();
466        mgr.subscribe(Capability::Camera, node(1));
467        mgr.subscribe(Capability::Camera, node(2));
468        assert_eq!(mgr.refcount(Capability::Camera), 2);
469        let events = mgr.take_pending_events();
470        // Exactly one Subscribe should have been emitted across both subscribes.
471        assert_eq!(events.len(), 1);
472    }
473
474    #[test]
475    fn release_only_after_last_subscriber_drops() {
476        let mut mgr = PermissionManager::new();
477        mgr.subscribe(Capability::Microphone, node(1));
478        mgr.subscribe(Capability::Microphone, node(2));
479        // Drain the initial Subscribe so the assertion below isolates Release.
480        drop(mgr.take_pending_events());
481
482        mgr.release(Capability::Microphone);
483        assert_eq!(mgr.refcount(Capability::Microphone), 1);
484        assert!(mgr.take_pending_events().is_empty());
485
486        mgr.release(Capability::Microphone);
487        assert_eq!(mgr.refcount(Capability::Microphone), 0);
488        let events = mgr.take_pending_events();
489        assert_eq!(events.len(), 1);
490        assert!(matches!(
491            events[0],
492            PermissionDiffEvent::Release { capability: Capability::Microphone }
493        ));
494    }
495
496    #[test]
497    fn force_release_drops_refcount_and_emits_event() {
498        let mut mgr = PermissionManager::new();
499        mgr.subscribe(Capability::Camera, node(1));
500        mgr.subscribe(Capability::Camera, node(2));
501        drop(mgr.take_pending_events());
502
503        mgr.force_release(Capability::Camera);
504        assert_eq!(mgr.refcount(Capability::Camera), 0);
505        let events = mgr.take_pending_events();
506        assert_eq!(events.len(), 1);
507        assert!(matches!(
508            events[0],
509            PermissionDiffEvent::Release { capability: Capability::Camera }
510        ));
511    }
512
513    #[test]
514    fn set_status_returns_change_flag() {
515        let mut mgr = PermissionManager::new();
516        assert!(mgr.set_status(Capability::Camera, PermissionState::Requested));
517        assert!(!mgr.set_status(Capability::Camera, PermissionState::Requested));
518        assert!(mgr.set_status(
519            Capability::Camera,
520            PermissionState::Granted(PermissionQuality::Full)
521        ));
522        assert!(mgr.get_status(Capability::Camera).is_granted());
523    }
524
525    #[test]
526    fn diff_layout_picks_up_appearing_node_and_releases_it_next_frame() {
527        let mut mgr = PermissionManager::new();
528
529        // Frame 1: GeolocationProbe present.
530        mgr.diff_layout(|emit| {
531            emit(Capability::Geolocation, node(7));
532        });
533        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
534        let events = mgr.take_pending_events();
535        assert_eq!(events.len(), 1);
536        assert!(matches!(
537            events[0],
538            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
539        ));
540
541        // Frame 2: probe removed.
542        mgr.diff_layout(|_emit| { /* no bearing nodes this frame */ });
543        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
544        let events = mgr.take_pending_events();
545        assert_eq!(events.len(), 1);
546        assert!(matches!(
547            events[0],
548            PermissionDiffEvent::Release { capability: Capability::Geolocation }
549        ));
550    }
551
552    #[test]
553    fn diff_layout_re_emits_subscribe_after_release_cycle() {
554        let mut mgr = PermissionManager::new();
555
556        mgr.diff_layout(|emit| emit(Capability::Camera, node(1)));
557        drop(mgr.take_pending_events());
558
559        mgr.diff_layout(|_emit| {});
560        drop(mgr.take_pending_events());
561
562        // Same capability reappears — must emit Subscribe again because the
563        // platform tore the session down on the prior Release.
564        mgr.diff_layout(|emit| emit(Capability::Camera, node(2)));
565        let events = mgr.take_pending_events();
566        assert_eq!(events.len(), 1);
567        assert!(matches!(
568            events[0],
569            PermissionDiffEvent::Subscribe { capability: Capability::Camera, .. }
570        ));
571    }
572
573    #[test]
574    fn async_results_round_trip_through_manager() {
575        // The channel is a process-global and libtest runs tests in parallel:
576        // serialize against every other test that touches it.
577        let _serialize = super::autotest_generated::lock_async_channel();
578        // The channel is a process-global; clear anything a prior test or
579        // ordering left behind so this test is self-contained.
580        drop(drain_async_results());
581
582        push_async_result(
583            Capability::Camera,
584            PermissionState::Granted(PermissionQuality::Full),
585        );
586        push_async_result(Capability::Geolocation, PermissionState::Denied);
587
588        let drained = drain_async_results();
589        assert_eq!(drained.len(), 2, "both parked results drain in order");
590        // Arrival order preserved.
591        assert_eq!(drained[0].0, Capability::Camera);
592        assert_eq!(drained[1].0, Capability::Geolocation);
593
594        // Applying them through the manager reflects in get_status — this is
595        // exactly what the dll layout pass does each frame.
596        let mut mgr = PermissionManager::new();
597        for (cap, state) in drained {
598            mgr.set_status(cap, state);
599        }
600        assert!(mgr.get_status(Capability::Camera).is_granted());
601        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::Denied);
602
603        // A second drain is empty — the queue was taken, not copied.
604        assert!(drain_async_results().is_empty());
605    }
606}
607
608#[cfg(test)]
609mod pump_provider_tests {
610    use super::*;
611    use azul_core::task::{Instant, SystemTick};
612
613    fn ts() -> Instant {
614        Instant::Tick(SystemTick::new(0))
615    }
616
617    #[test]
618    fn status_flip_yields_targeted_permission_changed_event() {
619        let node = DomNodeId::ROOT;
620        let mut mgr = PermissionManager::new();
621        mgr.subscribe(Capability::Geolocation, node);
622        drop(mgr.take_pending_events());
623        assert!(mgr.get_pending_events(ts()).is_empty(), "no flip yet");
624
625        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Requested));
626        assert!(mgr.has_pending_async(), "Requested = OS prompt in flight");
627        let events = mgr.get_pending_events(ts());
628        assert_eq!(events.len(), 1);
629        assert_eq!(
630            events[0].event_type,
631            EventType::PermissionChanged
632        );
633        assert_eq!(events[0].target, node, "targeted at the subscriber node");
634
635        mgr.clear_pending_changed();
636        assert!(mgr.get_pending_events(ts()).is_empty(), "cleared after dispatch");
637
638        assert!(mgr.set_status(
639            Capability::Geolocation,
640            PermissionState::Granted(PermissionQuality::Full),
641        ));
642        assert!(!mgr.has_pending_async(), "prompt resolved");
643        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
644    }
645
646    #[test]
647    fn unchanged_status_emits_no_event() {
648        let mut mgr = PermissionManager::new();
649        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Denied));
650        mgr.clear_pending_changed();
651        assert!(!mgr.set_status(Capability::Geolocation, PermissionState::Denied));
652        assert!(mgr.get_pending_events(ts()).is_empty());
653    }
654}
655
656#[cfg(test)]
657mod autotest_generated {
658    use alloc::collections::BTreeSet;
659
660    use azul_core::dom::{DomId, NodeId};
661    use azul_core::task::{Instant, SystemTick};
662
663    use super::*;
664    use crate::managers::{NodeIdMap, NodeIdRemap};
665
666    // ── fixtures ────────────────────────────────────────────────────────
667
668    /// Every `Capability` variant, in declaration order. Kept honest by
669    /// `all_capabilities_are_distinct_and_totally_ordered` below, whose
670    /// exhaustive `match` fails to compile if a variant is ever added.
671    const ALL_CAPS: [Capability; 18] = [
672        Capability::Camera,
673        Capability::Microphone,
674        Capability::ScreenCapture,
675        Capability::Geolocation,
676        Capability::GeolocationBackground,
677        Capability::Biometric,
678        Capability::Motion,
679        Capability::PhotoLibrary,
680        Capability::PhotoLibraryWrite,
681        Capability::Contacts,
682        Capability::Calendars,
683        Capability::Reminders,
684        Capability::Notifications,
685        Capability::Bluetooth,
686        Capability::BluetoothBackground,
687        Capability::NearbyWifi,
688        Capability::LocalNetwork,
689        Capability::AppTrackingTransparency,
690    ];
691
692    /// Every distinct `PermissionState` value, including both payloads of the
693    /// two data-carrying variants.
694    const ALL_STATES: [PermissionState; 8] = [
695        PermissionState::NotDetermined,
696        PermissionState::Requested,
697        PermissionState::Granted(PermissionQuality::Full),
698        PermissionState::Granted(PermissionQuality::Reduced),
699        PermissionState::Denied,
700        PermissionState::Restricted,
701        PermissionState::EphemeralGranted(true),
702        PermissionState::EphemeralGranted(false),
703    ];
704
705    /// `NodeId::from_usize` is 1-based: `node(0)` is the `None` sentinel (==
706    /// `DomNodeId::ROOT`), `node(1)` is `NodeId(0)`.
707    fn node(idx: usize) -> DomNodeId {
708        DomNodeId {
709            dom: DomId::ROOT_ID,
710            node: NodeId::from_usize(idx).into(),
711        }
712    }
713
714    fn node_in_dom(dom: usize, idx: usize) -> DomNodeId {
715        DomNodeId {
716            dom: DomId { inner: dom },
717            node: NodeId::from_usize(idx).into(),
718        }
719    }
720
721    fn ts(tick: u64) -> Instant {
722        Instant::Tick(SystemTick::new(tick))
723    }
724
725    /// Serializes every test that touches the process-global `ASYNC_RESULTS`
726    /// queue. libtest runs tests in parallel threads inside one process, so
727    /// without this a concurrent `push_async_result` would corrupt another
728    /// test's drain. Recovers from poisoning so one failing test cannot wedge
729    /// the rest of the suite.
730    pub(super) fn lock_async_channel() -> std::sync::MutexGuard<'static, ()> {
731        static ASYNC_CHANNEL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
732        ASYNC_CHANNEL_TEST_LOCK
733            .lock()
734            .unwrap_or_else(std::sync::PoisonError::into_inner)
735    }
736
737    // ── Capability: enum hygiene ────────────────────────────────────────
738
739    #[test]
740    fn all_capabilities_are_distinct_and_totally_ordered() {
741        // Exhaustiveness guard: adding a variant to `Capability` makes this
742        // match non-exhaustive, which is the compile error that says "add it
743        // to ALL_CAPS too". Combined with the distinctness check below, that
744        // means ALL_CAPS provably covers every variant.
745        for cap in ALL_CAPS {
746            match cap {
747                Capability::Camera
748                | Capability::Microphone
749                | Capability::ScreenCapture
750                | Capability::Geolocation
751                | Capability::GeolocationBackground
752                | Capability::Biometric
753                | Capability::Motion
754                | Capability::PhotoLibrary
755                | Capability::PhotoLibraryWrite
756                | Capability::Contacts
757                | Capability::Calendars
758                | Capability::Reminders
759                | Capability::Notifications
760                | Capability::Bluetooth
761                | Capability::BluetoothBackground
762                | Capability::NearbyWifi
763                | Capability::LocalNetwork
764                | Capability::AppTrackingTransparency => {}
765            }
766        }
767
768        let set: BTreeSet<Capability> = ALL_CAPS.iter().copied().collect();
769        assert_eq!(set.len(), ALL_CAPS.len(), "ALL_CAPS contains a duplicate");
770
771        // The derived Ord follows declaration order — the BTreeMap key order
772        // (and therefore the diff-event order) depends on it.
773        let mut sorted = ALL_CAPS.to_vec();
774        sorted.sort_unstable();
775        assert_eq!(sorted, ALL_CAPS.to_vec());
776    }
777
778    #[test]
779    fn all_states_are_distinct() {
780        // Same guard for the state machine: a new variant fails to compile.
781        for state in ALL_STATES {
782            match state {
783                PermissionState::NotDetermined
784                | PermissionState::Requested
785                | PermissionState::Granted(_)
786                | PermissionState::Denied
787                | PermissionState::Restricted
788                | PermissionState::EphemeralGranted(_) => {}
789            }
790        }
791        let set: BTreeSet<PermissionState> = ALL_STATES.iter().copied().collect();
792        assert_eq!(set.len(), ALL_STATES.len(), "ALL_STATES contains a duplicate");
793    }
794
795    // ── PermissionState predicates ──────────────────────────────────────
796
797    #[test]
798    fn is_granted_matches_the_documented_state_matrix() {
799        assert!(!PermissionState::NotDetermined.is_granted());
800        assert!(!PermissionState::Requested.is_granted());
801        assert!(PermissionState::Granted(PermissionQuality::Full).is_granted());
802        assert!(
803            PermissionState::Granted(PermissionQuality::Reduced).is_granted(),
804            "reduced quality is still usable — `is_granted` ignores quality"
805        );
806        assert!(!PermissionState::Denied.is_granted());
807        assert!(!PermissionState::Restricted.is_granted());
808        // NOTE: the bool payload of EphemeralGranted is ignored by `is_granted`
809        // (the `matches!` uses `..`), so BOTH payloads report granted.
810        assert!(PermissionState::EphemeralGranted(true).is_granted());
811        assert!(PermissionState::EphemeralGranted(false).is_granted());
812    }
813
814    #[test]
815    fn could_re_prompt_is_true_only_for_not_determined() {
816        for state in ALL_STATES {
817            let expected = state == PermissionState::NotDetermined;
818            assert_eq!(
819                state.could_re_prompt(),
820                expected,
821                "could_re_prompt({state:?}) should be {expected}"
822            );
823        }
824        // Denied is explicitly NOT re-promptable — the OS suppresses the
825        // second prompt, so callers must deep-link to settings instead.
826        assert!(!PermissionState::Denied.could_re_prompt());
827        assert!(!PermissionState::Restricted.could_re_prompt());
828    }
829
830    #[test]
831    fn granted_and_re_promptable_are_mutually_exclusive() {
832        for state in ALL_STATES {
833            assert!(
834                !(state.is_granted() && state.could_re_prompt()),
835                "{state:?} claims to be both granted and re-promptable"
836            );
837        }
838    }
839
840    #[test]
841    fn predicates_are_usable_in_const_context() {
842        // Both are `const fn`; a regression to a non-const body would fail to
843        // compile here rather than silently break `no_std` callers.
844        const GRANTED: bool = PermissionState::Granted(PermissionQuality::Reduced).is_granted();
845        const RE_PROMPT: bool = PermissionState::NotDetermined.could_re_prompt();
846        const DENIED_GRANTED: bool = PermissionState::Denied.is_granted();
847        const _: () = assert!(GRANTED && RE_PROMPT && !DENIED_GRANTED);
848    }
849
850    // ── constructors ────────────────────────────────────────────────────
851
852    #[test]
853    fn capability_entry_new_is_the_zero_state() {
854        let e = CapabilityEntry::new();
855        assert_eq!(e.state, PermissionState::NotDetermined);
856        assert_eq!(e.refcount, 0);
857        assert_eq!(e.last_subscriber, None);
858        // Deterministic: two constructions are indistinguishable.
859        assert_eq!(e, CapabilityEntry::new());
860        // A fresh entry is re-promptable and not usable — the invariant every
861        // caller assumes before the first prompt.
862        assert!(!e.state.is_granted());
863        assert!(e.state.could_re_prompt());
864    }
865
866    #[test]
867    fn new_manager_is_empty_for_every_capability() {
868        let mgr = PermissionManager::new();
869        assert_eq!(mgr, PermissionManager::default());
870        assert!(mgr.statuses.is_empty());
871        assert!(!mgr.has_pending_async());
872        assert!(mgr.get_pending_events(ts(0)).is_empty());
873
874        for cap in ALL_CAPS {
875            assert_eq!(mgr.get_status(cap), PermissionState::NotDetermined);
876            assert_eq!(mgr.refcount(cap), 0);
877        }
878        // Reading must not lazily create entries — an entry with refcount 0
879        // would make `diff_layout` iterate capabilities nobody ever used.
880        assert!(mgr.statuses.is_empty(), "get_status/refcount created entries");
881    }
882
883    #[test]
884    fn take_pending_events_on_a_fresh_manager_is_empty_and_idempotent() {
885        let mut mgr = PermissionManager::new();
886        assert!(mgr.take_pending_events().is_empty());
887        assert!(mgr.take_pending_events().is_empty());
888        mgr.clear_pending_changed();
889        mgr.clear_pending_changed();
890        assert_eq!(mgr, PermissionManager::new());
891    }
892
893    // ── refcount arithmetic: underflow / overflow / saturation ──────────
894
895    #[test]
896    fn release_on_unknown_capability_is_a_noop() {
897        let mut mgr = PermissionManager::new();
898        for cap in ALL_CAPS {
899            mgr.release(cap);
900            mgr.force_release(cap);
901        }
902        assert!(mgr.statuses.is_empty(), "release must not create entries");
903        assert!(mgr.take_pending_events().is_empty());
904        assert!(mgr.get_pending_events(ts(0)).is_empty());
905    }
906
907    #[test]
908    fn release_at_zero_refcount_does_not_underflow() {
909        let mut mgr = PermissionManager::new();
910        // set_status creates the entry with refcount 0 — the exact shape that
911        // would panic on `refcount -= 1` in debug builds if unguarded.
912        mgr.set_status(Capability::Camera, PermissionState::Denied);
913        assert_eq!(mgr.refcount(Capability::Camera), 0);
914
915        for _ in 0..8 {
916            mgr.release(Capability::Camera);
917        }
918        assert_eq!(mgr.refcount(Capability::Camera), 0, "refcount wrapped around");
919        assert!(
920            mgr.take_pending_events().is_empty(),
921            "a release that never had a subscriber must not emit Release"
922        );
923    }
924
925    #[test]
926    fn double_release_emits_exactly_one_release_event() {
927        let mut mgr = PermissionManager::new();
928        mgr.subscribe(Capability::Motion, node(1));
929        drop(mgr.take_pending_events());
930
931        mgr.release(Capability::Motion);
932        mgr.release(Capability::Motion);
933        mgr.release(Capability::Motion);
934
935        assert_eq!(mgr.refcount(Capability::Motion), 0);
936        let events = mgr.take_pending_events();
937        assert_eq!(
938            events.len(),
939            1,
940            "over-releasing must not double-tear-down the native session: {events:?}"
941        );
942    }
943
944    #[test]
945    fn subscribe_saturates_at_u32_max_instead_of_overflowing() {
946        let mut mgr = PermissionManager::new();
947        mgr.subscribe(Capability::Bluetooth, node(1));
948        drop(mgr.take_pending_events());
949        // Reach the boundary directly — 4 billion subscribe() calls is not a
950        // test. `statuses` is a pub field, so this is a supported shortcut.
951        mgr.statuses.get_mut(&Capability::Bluetooth).unwrap().refcount = u32::MAX;
952
953        mgr.subscribe(Capability::Bluetooth, node(2));
954
955        assert_eq!(mgr.refcount(Capability::Bluetooth), u32::MAX, "refcount wrapped to 0");
956        assert!(
957            mgr.take_pending_events().is_empty(),
958            "a saturating subscribe must not look like a 0 -> 1 transition"
959        );
960        // The subscriber is still tracked even when the count saturates.
961        assert_eq!(
962            mgr.statuses[&Capability::Bluetooth].last_subscriber,
963            Some(node(2))
964        );
965    }
966
967    #[test]
968    fn release_from_u32_max_does_not_wrap_or_emit() {
969        let mut mgr = PermissionManager::new();
970        mgr.subscribe(Capability::Contacts, node(1));
971        drop(mgr.take_pending_events());
972        mgr.statuses.get_mut(&Capability::Contacts).unwrap().refcount = u32::MAX;
973
974        mgr.release(Capability::Contacts);
975
976        assert_eq!(mgr.refcount(Capability::Contacts), u32::MAX - 1);
977        assert!(mgr.take_pending_events().is_empty());
978    }
979
980    #[test]
981    fn force_release_from_saturated_refcount_emits_one_release() {
982        let mut mgr = PermissionManager::new();
983        mgr.subscribe(Capability::ScreenCapture, node(1));
984        drop(mgr.take_pending_events());
985        mgr.statuses.get_mut(&Capability::ScreenCapture).unwrap().refcount = u32::MAX;
986
987        mgr.force_release(Capability::ScreenCapture);
988        assert_eq!(mgr.refcount(Capability::ScreenCapture), 0);
989        assert_eq!(
990            mgr.statuses[&Capability::ScreenCapture].last_subscriber,
991            None
992        );
993        assert_eq!(mgr.take_pending_events().len(), 1);
994
995        // Second force_release: refcount is already 0, nothing to tear down.
996        mgr.force_release(Capability::ScreenCapture);
997        assert!(
998            mgr.take_pending_events().is_empty(),
999            "force_release on a zero refcount must be a no-op"
1000        );
1001    }
1002
1003    #[test]
1004    fn subscribe_after_a_full_release_re_emits_subscribe() {
1005        let mut mgr = PermissionManager::new();
1006        mgr.subscribe(Capability::Camera, node(1));
1007        mgr.release(Capability::Camera);
1008        drop(mgr.take_pending_events());
1009
1010        // The platform tore the session down on Release, so the reappearing
1011        // node must produce a fresh Subscribe (0 -> 1 again).
1012        mgr.subscribe(Capability::Camera, node(2));
1013        let events = mgr.take_pending_events();
1014        assert_eq!(
1015            events,
1016            [PermissionDiffEvent::Subscribe {
1017                capability: Capability::Camera,
1018                node_id: node(2),
1019            }]
1020            .to_vec()
1021        );
1022    }
1023
1024    #[test]
1025    fn release_cycle_preserves_the_os_observed_state() {
1026        let mut mgr = PermissionManager::new();
1027        mgr.subscribe(Capability::Geolocation, node(1));
1028        mgr.set_status(
1029            Capability::Geolocation,
1030            PermissionState::Granted(PermissionQuality::Reduced),
1031        );
1032        mgr.release(Capability::Geolocation);
1033
1034        // Refcount is gone but the grant is NOT forgotten — otherwise every
1035        // layout pass that drops the node would re-prompt the user.
1036        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
1037        assert_eq!(
1038            mgr.get_status(Capability::Geolocation),
1039            PermissionState::Granted(PermissionQuality::Reduced)
1040        );
1041        assert_eq!(mgr.statuses[&Capability::Geolocation].last_subscriber, None);
1042    }
1043
1044    #[test]
1045    fn subscribe_accepts_boundary_node_ids() {
1046        let mut mgr = PermissionManager::new();
1047        // The `None` sentinel (== DomNodeId::ROOT) and the largest encodable
1048        // NodeId must both round-trip through the event queue untouched.
1049        mgr.subscribe(Capability::Notifications, DomNodeId::ROOT);
1050        mgr.subscribe(Capability::Biometric, node(usize::MAX));
1051
1052        let events = mgr.take_pending_events();
1053        assert_eq!(events.len(), 2);
1054        assert!(events.contains(&PermissionDiffEvent::Subscribe {
1055            capability: Capability::Notifications,
1056            node_id: DomNodeId::ROOT,
1057        }));
1058        assert!(events.contains(&PermissionDiffEvent::Subscribe {
1059            capability: Capability::Biometric,
1060            node_id: node(usize::MAX),
1061        }));
1062    }
1063
1064    // ── set_status / pending_changed ────────────────────────────────────
1065
1066    #[test]
1067    fn set_status_get_status_round_trips_over_the_whole_matrix() {
1068        for cap in ALL_CAPS {
1069            for state in ALL_STATES {
1070                let mut mgr = PermissionManager::new();
1071                mgr.set_status(cap, state);
1072                assert_eq!(mgr.get_status(cap), state, "{cap:?} / {state:?} did not round-trip");
1073                // Writing one capability must not leak into any other.
1074                for other in ALL_CAPS.iter().copied().filter(|c| *c != cap) {
1075                    assert_eq!(mgr.get_status(other), PermissionState::NotDetermined);
1076                }
1077            }
1078        }
1079    }
1080
1081    #[test]
1082    fn set_status_change_flag_is_exact_over_every_transition() {
1083        for a in ALL_STATES {
1084            for b in ALL_STATES {
1085                let mut mgr = PermissionManager::new();
1086                let cap = Capability::Calendars;
1087
1088                // The entry starts at NotDetermined, so writing NotDetermined
1089                // first is a no-change write.
1090                let changed_a = mgr.set_status(cap, a);
1091                assert_eq!(changed_a, a != PermissionState::NotDetermined, "{a:?}");
1092
1093                let changed_b = mgr.set_status(cap, b);
1094                assert_eq!(changed_b, b != a, "{a:?} -> {b:?} reported the wrong flag");
1095
1096                assert_eq!(mgr.get_status(cap), b);
1097                assert_eq!(mgr.has_pending_async(), b == PermissionState::Requested);
1098
1099                // One PermissionChanged event per *actual* flip, no more.
1100                let expected = usize::from(changed_a) + usize::from(changed_b);
1101                assert_eq!(mgr.get_pending_events(ts(0)).len(), expected, "{a:?} -> {b:?}");
1102            }
1103        }
1104    }
1105
1106    #[test]
1107    fn set_status_creates_an_entry_with_a_zero_refcount() {
1108        let mut mgr = PermissionManager::new();
1109        assert!(mgr.set_status(Capability::Reminders, PermissionState::Restricted));
1110        let entry = mgr.statuses[&Capability::Reminders];
1111        assert_eq!(entry.refcount, 0, "a status write is not a subscription");
1112        assert_eq!(entry.last_subscriber, None);
1113        assert!(mgr.take_pending_events().is_empty(), "set_status is not a diff event");
1114    }
1115
1116    #[test]
1117    fn quality_and_payload_changes_count_as_state_changes() {
1118        let mut mgr = PermissionManager::new();
1119        let cap = Capability::PhotoLibrary;
1120        assert!(mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full)));
1121        // Full -> Reduced ("Selected Photos") is a real change the UI must see.
1122        assert!(mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Reduced)));
1123        assert!(!mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Reduced)));
1124        // Both EphemeralGranted payloads are distinct states.
1125        assert!(mgr.set_status(cap, PermissionState::EphemeralGranted(true)));
1126        assert!(mgr.set_status(cap, PermissionState::EphemeralGranted(false)));
1127        assert!(!mgr.set_status(cap, PermissionState::EphemeralGranted(false)));
1128        assert!(mgr.get_status(cap).is_granted());
1129    }
1130
1131    #[test]
1132    fn has_pending_async_tracks_only_the_requested_state() {
1133        for state in ALL_STATES {
1134            let mut mgr = PermissionManager::new();
1135            mgr.set_status(Capability::Camera, state);
1136            assert_eq!(
1137                mgr.has_pending_async(),
1138                state == PermissionState::Requested,
1139                "has_pending_async({state:?})"
1140            );
1141        }
1142
1143        // One in-flight prompt among many resolved capabilities still arms the
1144        // pump — otherwise the outcome never reaches callbacks in an idle app.
1145        let mut mgr = PermissionManager::new();
1146        for cap in ALL_CAPS {
1147            mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full));
1148        }
1149        assert!(!mgr.has_pending_async());
1150        mgr.set_status(Capability::AppTrackingTransparency, PermissionState::Requested);
1151        assert!(mgr.has_pending_async());
1152        mgr.set_status(Capability::AppTrackingTransparency, PermissionState::Denied);
1153        assert!(!mgr.has_pending_async());
1154    }
1155
1156    #[test]
1157    fn pending_changed_targets_the_root_when_there_is_no_subscriber() {
1158        let mut mgr = PermissionManager::new();
1159        // A status flip with no bearing node (OS revoked it while nothing was
1160        // mounted) must still dispatch — targeted at the window root.
1161        mgr.set_status(Capability::Microphone, PermissionState::Denied);
1162        let events = mgr.get_pending_events(ts(42));
1163        assert_eq!(events.len(), 1);
1164        assert_eq!(events[0].event_type, EventType::PermissionChanged);
1165        assert_eq!(events[0].source, CoreEventSource::User);
1166        assert_eq!(events[0].target, DomNodeId::ROOT);
1167        assert_eq!(events[0].timestamp, ts(42), "the caller's timestamp is preserved");
1168    }
1169
1170    #[test]
1171    fn pending_changed_falls_back_to_root_after_the_subscriber_leaves() {
1172        let mut mgr = PermissionManager::new();
1173        mgr.subscribe(Capability::Microphone, node(4));
1174        mgr.set_status(Capability::Microphone, PermissionState::Requested);
1175        assert_eq!(mgr.get_pending_events(ts(0))[0].target, node(4));
1176        mgr.clear_pending_changed();
1177
1178        // The node unmounts, then the OS answer lands: last_subscriber is
1179        // cleared, so the event must NOT point at a stale node index.
1180        mgr.release(Capability::Microphone);
1181        mgr.set_status(Capability::Microphone, PermissionState::Denied);
1182        let events = mgr.get_pending_events(ts(0));
1183        assert_eq!(events.len(), 1);
1184        assert_eq!(events[0].target, DomNodeId::ROOT);
1185    }
1186
1187    #[test]
1188    fn repeated_flips_accumulate_one_event_each_until_cleared() {
1189        let mut mgr = PermissionManager::new();
1190        let cap = Capability::LocalNetwork;
1191        mgr.set_status(cap, PermissionState::Requested);
1192        mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full));
1193        mgr.set_status(cap, PermissionState::Denied);
1194        assert_eq!(mgr.get_pending_events(ts(0)).len(), 3);
1195        // get_pending_events is a read, not a drain.
1196        assert_eq!(mgr.get_pending_events(ts(0)).len(), 3);
1197
1198        mgr.clear_pending_changed();
1199        assert!(mgr.get_pending_events(ts(0)).is_empty());
1200        mgr.clear_pending_changed();
1201        assert!(mgr.get_pending_events(ts(0)).is_empty(), "clear is idempotent");
1202    }
1203
1204    #[test]
1205    fn the_two_queues_drain_independently() {
1206        let mut mgr = PermissionManager::new();
1207        mgr.subscribe(Capability::NearbyWifi, node(1)); // -> pending_events
1208        mgr.set_status(Capability::NearbyWifi, PermissionState::Requested); // -> pending_changed
1209
1210        // Clearing the state-flip queue must not swallow the diff events the
1211        // platform backend has not drained yet.
1212        mgr.clear_pending_changed();
1213        assert!(mgr.get_pending_events(ts(0)).is_empty());
1214        assert_eq!(mgr.take_pending_events().len(), 1);
1215
1216        // ... and vice versa.
1217        mgr.set_status(Capability::NearbyWifi, PermissionState::Denied);
1218        mgr.subscribe(Capability::Contacts, node(2));
1219        assert_eq!(mgr.take_pending_events().len(), 1);
1220        assert!(mgr.take_pending_events().is_empty(), "take drains");
1221        assert_eq!(
1222            mgr.get_pending_events(ts(0)).len(),
1223            1,
1224            "take_pending_events must not clear pending_changed"
1225        );
1226    }
1227
1228    // ── diff_layout ─────────────────────────────────────────────────────
1229
1230    #[test]
1231    fn diff_layout_on_an_empty_manager_with_no_nodes_is_a_noop() {
1232        let mut mgr = PermissionManager::new();
1233        mgr.diff_layout(|_emit| {});
1234        assert!(mgr.statuses.is_empty());
1235        assert!(mgr.take_pending_events().is_empty());
1236        assert!(mgr.get_pending_events(ts(0)).is_empty());
1237    }
1238
1239    #[test]
1240    fn diff_layout_counts_duplicates_and_anchors_the_first_node() {
1241        let mut mgr = PermissionManager::new();
1242        mgr.diff_layout(|emit| {
1243            emit(Capability::Camera, node(3));
1244            emit(Capability::Camera, node(4));
1245            emit(Capability::Camera, node(5));
1246        });
1247
1248        assert_eq!(mgr.refcount(Capability::Camera), 3);
1249        assert_eq!(
1250            mgr.statuses[&Capability::Camera].last_subscriber,
1251            Some(node(3)),
1252            "the FIRST emitted node anchors the capability"
1253        );
1254        assert_eq!(
1255            mgr.take_pending_events(),
1256            [PermissionDiffEvent::Subscribe {
1257                capability: Capability::Camera,
1258                node_id: node(3),
1259            }]
1260            .to_vec(),
1261            "three bearing nodes still mean exactly one native subscribe"
1262        );
1263    }
1264
1265    #[test]
1266    fn diff_layout_is_idempotent_for_a_stable_layout() {
1267        let mut mgr = PermissionManager::new();
1268        for _ in 0..5 {
1269            mgr.diff_layout(|emit| {
1270                emit(Capability::Camera, node(1));
1271                emit(Capability::Microphone, node(2));
1272            });
1273        }
1274        assert_eq!(mgr.refcount(Capability::Camera), 1);
1275        assert_eq!(mgr.refcount(Capability::Microphone), 1);
1276        assert_eq!(
1277            mgr.take_pending_events().len(),
1278            2,
1279            "an unchanged layout must not re-emit subscribes every frame"
1280        );
1281    }
1282
1283    #[test]
1284    fn diff_layout_emits_release_and_subscribe_in_the_same_frame() {
1285        let mut mgr = PermissionManager::new();
1286        mgr.diff_layout(|emit| emit(Capability::Camera, node(1)));
1287        drop(mgr.take_pending_events());
1288
1289        // The camera node is swapped for a geolocation node in one pass.
1290        mgr.diff_layout(|emit| emit(Capability::Geolocation, node(2)));
1291
1292        let events = mgr.take_pending_events();
1293        assert_eq!(events.len(), 2, "{events:?}");
1294        assert!(events.contains(&PermissionDiffEvent::Release {
1295            capability: Capability::Camera
1296        }));
1297        assert!(events.contains(&PermissionDiffEvent::Subscribe {
1298            capability: Capability::Geolocation,
1299            node_id: node(2),
1300        }));
1301        assert_eq!(mgr.refcount(Capability::Camera), 0);
1302        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
1303    }
1304
1305    #[test]
1306    fn diff_layout_reconciles_a_manually_subscribed_capability() {
1307        let mut mgr = PermissionManager::new();
1308        mgr.subscribe(Capability::Camera, node(1));
1309        mgr.subscribe(Capability::Camera, node(2));
1310        drop(mgr.take_pending_events());
1311
1312        // The layout pass is authoritative: no bearing nodes this frame means
1313        // the refcount goes to 0 outright, not 2 -> 1.
1314        mgr.diff_layout(|_emit| {});
1315        assert_eq!(mgr.refcount(Capability::Camera), 0);
1316        assert_eq!(mgr.statuses[&Capability::Camera].last_subscriber, None);
1317        assert_eq!(
1318            mgr.take_pending_events(),
1319            [PermissionDiffEvent::Release {
1320                capability: Capability::Camera
1321            }]
1322            .to_vec()
1323        );
1324    }
1325
1326    #[test]
1327    fn diff_layout_ignores_capabilities_that_only_have_a_status() {
1328        let mut mgr = PermissionManager::new();
1329        // set_status leaves a refcount-0 entry behind; diff_layout iterates
1330        // every known capability, so it must not emit a spurious Release.
1331        for cap in ALL_CAPS {
1332            mgr.set_status(cap, PermissionState::Denied);
1333        }
1334        mgr.diff_layout(|_emit| {});
1335        assert!(
1336            mgr.take_pending_events().is_empty(),
1337            "0 -> 0 is not a transition"
1338        );
1339        for cap in ALL_CAPS {
1340            assert_eq!(mgr.refcount(cap), 0);
1341            assert_eq!(mgr.get_status(cap), PermissionState::Denied);
1342        }
1343    }
1344
1345    #[test]
1346    fn diff_layout_from_a_saturated_refcount_does_not_emit() {
1347        let mut mgr = PermissionManager::new();
1348        mgr.subscribe(Capability::Motion, node(1));
1349        drop(mgr.take_pending_events());
1350        mgr.statuses.get_mut(&Capability::Motion).unwrap().refcount = u32::MAX;
1351
1352        // MAX -> 1 is neither a 0 -> 1 nor an n -> 0 transition: the session
1353        // stays up and nothing is emitted.
1354        mgr.diff_layout(|emit| emit(Capability::Motion, node(1)));
1355        assert_eq!(mgr.refcount(Capability::Motion), 1);
1356        assert!(mgr.take_pending_events().is_empty());
1357    }
1358
1359    #[test]
1360    fn diff_layout_handles_every_capability_at_once() {
1361        let mut mgr = PermissionManager::new();
1362        mgr.diff_layout(|emit| {
1363            for (i, cap) in ALL_CAPS.iter().enumerate() {
1364                emit(*cap, node(i + 1));
1365            }
1366        });
1367
1368        let events = mgr.take_pending_events();
1369        assert_eq!(events.len(), ALL_CAPS.len(), "one Subscribe per capability");
1370        for (i, cap) in ALL_CAPS.iter().enumerate() {
1371            assert_eq!(mgr.refcount(*cap), 1);
1372            assert!(
1373                events.contains(&PermissionDiffEvent::Subscribe {
1374                    capability: *cap,
1375                    node_id: node(i + 1),
1376                }),
1377                "missing Subscribe for {cap:?}"
1378            );
1379        }
1380
1381        // ... and tears them all down again.
1382        mgr.diff_layout(|_emit| {});
1383        assert_eq!(mgr.take_pending_events().len(), ALL_CAPS.len());
1384    }
1385
1386    #[test]
1387    fn diff_layout_event_order_is_deterministic() {
1388        let run = || {
1389            let mut mgr = PermissionManager::new();
1390            mgr.diff_layout(|emit| {
1391                emit(Capability::Notifications, node(1));
1392                emit(Capability::Camera, node(2));
1393                emit(Capability::Geolocation, node(3));
1394            });
1395            let first = mgr.take_pending_events();
1396            mgr.diff_layout(|emit| emit(Capability::Camera, node(2)));
1397            let second = mgr.take_pending_events();
1398            (first, second)
1399        };
1400        assert_eq!(run(), run(), "the diff-event order must not depend on run order");
1401    }
1402
1403    #[test]
1404    fn diff_layout_accepts_the_root_sentinel_as_a_bearing_node() {
1405        let mut mgr = PermissionManager::new();
1406        // A bearing node whose NodeHierarchyItemId is NONE encodes to exactly
1407        // the same value as the `unwrap_or(ROOT)` fallback — assert we still
1408        // subscribe rather than panicking or skipping it.
1409        mgr.diff_layout(|emit| emit(Capability::Biometric, DomNodeId::ROOT));
1410        assert_eq!(mgr.refcount(Capability::Biometric), 1);
1411        assert_eq!(
1412            mgr.take_pending_events(),
1413            [PermissionDiffEvent::Subscribe {
1414                capability: Capability::Biometric,
1415                node_id: DomNodeId::ROOT,
1416            }]
1417            .to_vec()
1418        );
1419    }
1420
1421    #[test]
1422    fn diff_layout_handles_a_large_bearing_node_set() {
1423        let mut mgr = PermissionManager::new();
1424        const N: usize = 10_000;
1425        mgr.diff_layout(|emit| {
1426            for i in 1..=N {
1427                emit(Capability::PhotoLibraryWrite, node(i));
1428            }
1429        });
1430        assert_eq!(mgr.refcount(Capability::PhotoLibraryWrite), N as u32);
1431        assert_eq!(mgr.take_pending_events().len(), 1);
1432
1433        mgr.diff_layout(|_emit| {});
1434        assert_eq!(mgr.refcount(Capability::PhotoLibraryWrite), 0);
1435        assert_eq!(mgr.take_pending_events().len(), 1);
1436    }
1437
1438    #[test]
1439    fn diff_layout_does_not_disturb_the_state_machine() {
1440        let mut mgr = PermissionManager::new();
1441        mgr.set_status(
1442            Capability::Geolocation,
1443            PermissionState::EphemeralGranted(true),
1444        );
1445        mgr.clear_pending_changed();
1446
1447        mgr.diff_layout(|emit| emit(Capability::Geolocation, node(1)));
1448        mgr.diff_layout(|_emit| {});
1449
1450        // Subscribe/Release churn must never mutate the OS-observed state or
1451        // synthesize a PermissionChanged event out of thin air.
1452        assert_eq!(
1453            mgr.get_status(Capability::Geolocation),
1454            PermissionState::EphemeralGranted(true)
1455        );
1456        assert!(mgr.get_pending_events(ts(0)).is_empty());
1457    }
1458
1459    // ── clone / equality ────────────────────────────────────────────────
1460
1461    #[test]
1462    fn cloning_a_manager_deep_copies_its_queues() {
1463        let mut mgr = PermissionManager::new();
1464        mgr.subscribe(Capability::Camera, node(1));
1465        mgr.set_status(Capability::Camera, PermissionState::Requested);
1466
1467        let mut clone = mgr.clone();
1468        assert_eq!(clone, mgr);
1469
1470        clone.subscribe(Capability::Camera, node(2));
1471        clone.force_release(Capability::Microphone);
1472        drop(clone.take_pending_events());
1473        clone.clear_pending_changed();
1474
1475        assert_ne!(clone, mgr, "the clone shares state with the original");
1476        assert_eq!(mgr.refcount(Capability::Camera), 1);
1477        assert_eq!(mgr.take_pending_events().len(), 1, "original's queue was drained");
1478        assert_eq!(mgr.get_pending_events(ts(0)).len(), 1);
1479    }
1480
1481    // ── NodeIdRemap ─────────────────────────────────────────────────────
1482
1483    #[test]
1484    fn remap_rewrites_the_subscriber_and_the_queued_target() {
1485        let mut mgr = PermissionManager::new();
1486        mgr.subscribe(Capability::Camera, node(3)); // node(3) == NodeId(2)
1487        mgr.set_status(Capability::Camera, PermissionState::Requested);
1488
1489        // The DOM was rebuilt and the bearing node moved 2 -> 9.
1490        let map = NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(9))]);
1491        mgr.remap_node_ids(DomId::ROOT_ID, &map);
1492
1493        assert_eq!(
1494            mgr.statuses[&Capability::Camera].last_subscriber,
1495            Some(node(10)), // node(10) == NodeId(9)
1496        );
1497        assert_eq!(mgr.get_pending_events(ts(0))[0].target, node(10));
1498    }
1499
1500    #[test]
1501    fn remap_drops_an_unmounted_subscriber_instead_of_recycling_the_index() {
1502        let mut mgr = PermissionManager::new();
1503        mgr.subscribe(Capability::Camera, node(3));
1504        mgr.set_status(Capability::Camera, PermissionState::Requested);
1505
1506        // Empty map == everything was unmounted.
1507        mgr.remap_node_ids(DomId::ROOT_ID, &NodeIdMap::default());
1508
1509        assert_eq!(
1510            mgr.statuses[&Capability::Camera].last_subscriber,
1511            None,
1512            "an unmounted subscriber must fall back to None, never to a live-but-wrong node"
1513        );
1514        assert_eq!(
1515            mgr.get_pending_events(ts(0))[0].target,
1516            DomNodeId::ROOT,
1517            "the queued event retargets to the window root"
1518        );
1519        // The permission state itself survives the DOM rebuild.
1520        assert_eq!(mgr.get_status(Capability::Camera), PermissionState::Requested);
1521    }
1522
1523    #[test]
1524    fn remap_leaves_nodes_from_other_doms_untouched() {
1525        let mut mgr = PermissionManager::new();
1526        let foreign = node_in_dom(7, 3);
1527        mgr.subscribe(Capability::Camera, foreign);
1528        mgr.set_status(Capability::Camera, PermissionState::Requested);
1529
1530        // A reconciliation of DOM 0 says nothing about DOM 7 — dropping the
1531        // subscriber here would silently retarget an iframe's prompt at the
1532        // root window.
1533        mgr.remap_node_ids(DomId::ROOT_ID, &NodeIdMap::default());
1534
1535        assert_eq!(
1536            mgr.statuses[&Capability::Camera].last_subscriber,
1537            Some(foreign)
1538        );
1539        assert_eq!(mgr.get_pending_events(ts(0))[0].target, foreign);
1540    }
1541
1542    // ── async channel (process-global) ──────────────────────────────────
1543
1544    #[test]
1545    fn async_channel_preserves_arrival_order_across_all_states() {
1546        let _serialize = lock_async_channel();
1547        drop(drain_async_results());
1548
1549        for state in ALL_STATES {
1550            push_async_result(Capability::Camera, state);
1551        }
1552        let drained = drain_async_results();
1553        assert_eq!(drained.len(), ALL_STATES.len());
1554        for (i, state) in ALL_STATES.iter().enumerate() {
1555            assert_eq!(drained[i], (Capability::Camera, *state));
1556        }
1557        assert!(drain_async_results().is_empty(), "the queue is taken, not copied");
1558    }
1559
1560    #[test]
1561    fn async_channel_recovers_from_a_poisoned_lock() {
1562        let _serialize = lock_async_channel();
1563        drop(drain_async_results());
1564
1565        // Poison the global mutex the way a panicking applier would: unwind
1566        // out of a live guard. (The panic message below is expected output if
1567        // this test ever fails; libtest swallows it while it passes.)
1568        let unwound = std::panic::catch_unwind(|| {
1569            let _guard = ASYNC_RESULTS.lock().unwrap();
1570            panic!("intentional: poisoning ASYNC_RESULTS");
1571        });
1572        assert!(unwound.is_err(), "the panic must have unwound through the guard");
1573        assert!(ASYNC_RESULTS.is_poisoned(), "the lock should now be poisoned");
1574
1575        // Documented contract: delivery keeps working after a poisoning.
1576        push_async_result(Capability::Geolocation, PermissionState::Denied);
1577        let drained = drain_async_results();
1578        assert_eq!(
1579            drained,
1580            [(Capability::Geolocation, PermissionState::Denied)].to_vec(),
1581            "a poisoned lock must not wedge permission delivery forever"
1582        );
1583        assert!(drain_async_results().is_empty());
1584    }
1585
1586    #[test]
1587    fn async_channel_survives_concurrent_pushers() {
1588        let _serialize = lock_async_channel();
1589        drop(drain_async_results());
1590
1591        const THREADS: usize = 8;
1592        const PER_THREAD: usize = 50;
1593
1594        let handles: Vec<_> = (0..THREADS)
1595            .map(|t| {
1596                let cap = ALL_CAPS[t];
1597                std::thread::spawn(move || {
1598                    for _ in 0..PER_THREAD {
1599                        push_async_result(cap, PermissionState::Granted(PermissionQuality::Full));
1600                    }
1601                })
1602            })
1603            .collect();
1604        for h in handles {
1605            h.join().expect("a pusher thread panicked");
1606        }
1607
1608        let drained = drain_async_results();
1609        assert_eq!(drained.len(), THREADS * PER_THREAD, "results were lost");
1610        for cap in ALL_CAPS.iter().take(THREADS) {
1611            let count = drained.iter().filter(|(c, _)| c == cap).count();
1612            assert_eq!(count, PER_THREAD, "{cap:?} lost results");
1613        }
1614        assert!(drain_async_results().is_empty());
1615    }
1616
1617    #[test]
1618    fn draining_an_empty_async_channel_is_safe_and_repeatable() {
1619        let _serialize = lock_async_channel();
1620        for _ in 0..3 {
1621            assert!(drain_async_results().is_empty());
1622        }
1623    }
1624}
1625
1626impl crate::managers::NodeIdRemap for PermissionManager {
1627    /// Remap the `last_subscriber` node of each capability (the node a
1628    /// `PermissionChanged` event is targeted at) and the queued
1629    /// `pending_changed` targets. An unmounted subscriber falls back to `None`
1630    /// (→ the event targets the window root), never to a recycled index.
1631    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
1632        for entry in self.statuses.values_mut() {
1633            if let Some(node) = entry.last_subscriber {
1634                entry.last_subscriber = map.resolve_dom_node_id(dom, node);
1635            }
1636        }
1637        for (_capability, node) in &mut self.pending_changed {
1638            if let Some(n) = *node {
1639                *node = map.resolve_dom_node_id(dom, n);
1640            }
1641        }
1642    }
1643}