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; clear anything a prior test or
576        // ordering left behind so this test is self-contained.
577        drop(drain_async_results());
578
579        push_async_result(
580            Capability::Camera,
581            PermissionState::Granted(PermissionQuality::Full),
582        );
583        push_async_result(Capability::Geolocation, PermissionState::Denied);
584
585        let drained = drain_async_results();
586        assert_eq!(drained.len(), 2, "both parked results drain in order");
587        // Arrival order preserved.
588        assert_eq!(drained[0].0, Capability::Camera);
589        assert_eq!(drained[1].0, Capability::Geolocation);
590
591        // Applying them through the manager reflects in get_status — this is
592        // exactly what the dll layout pass does each frame.
593        let mut mgr = PermissionManager::new();
594        for (cap, state) in drained {
595            mgr.set_status(cap, state);
596        }
597        assert!(mgr.get_status(Capability::Camera).is_granted());
598        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::Denied);
599
600        // A second drain is empty — the queue was taken, not copied.
601        assert!(drain_async_results().is_empty());
602    }
603}
604
605#[cfg(test)]
606mod pump_provider_tests {
607    use super::*;
608    use azul_core::task::{Instant, SystemTick};
609
610    fn ts() -> Instant {
611        Instant::Tick(SystemTick::new(0))
612    }
613
614    #[test]
615    fn status_flip_yields_targeted_permission_changed_event() {
616        let node = DomNodeId::ROOT;
617        let mut mgr = PermissionManager::new();
618        mgr.subscribe(Capability::Geolocation, node);
619        drop(mgr.take_pending_events());
620        assert!(mgr.get_pending_events(ts()).is_empty(), "no flip yet");
621
622        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Requested));
623        assert!(mgr.has_pending_async(), "Requested = OS prompt in flight");
624        let events = mgr.get_pending_events(ts());
625        assert_eq!(events.len(), 1);
626        assert_eq!(
627            events[0].event_type,
628            EventType::PermissionChanged
629        );
630        assert_eq!(events[0].target, node, "targeted at the subscriber node");
631
632        mgr.clear_pending_changed();
633        assert!(mgr.get_pending_events(ts()).is_empty(), "cleared after dispatch");
634
635        assert!(mgr.set_status(
636            Capability::Geolocation,
637            PermissionState::Granted(PermissionQuality::Full),
638        ));
639        assert!(!mgr.has_pending_async(), "prompt resolved");
640        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
641    }
642
643    #[test]
644    fn unchanged_status_emits_no_event() {
645        let mut mgr = PermissionManager::new();
646        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Denied));
647        mgr.clear_pending_changed();
648        assert!(!mgr.set_status(Capability::Geolocation, PermissionState::Denied));
649        assert!(mgr.get_pending_events(ts()).is_empty());
650    }
651}
652
653impl crate::managers::NodeIdRemap for PermissionManager {
654    /// Remap the `last_subscriber` node of each capability (the node a
655    /// `PermissionChanged` event is targeted at) and the queued
656    /// `pending_changed` targets. An unmounted subscriber falls back to `None`
657    /// (→ the event targets the window root), never to a recycled index.
658    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
659        for entry in self.statuses.values_mut() {
660            if let Some(node) = entry.last_subscriber {
661                entry.last_subscriber = map.resolve_dom_node_id(dom, node);
662            }
663        }
664        for (_capability, node) in &mut self.pending_changed {
665            if let Some(n) = *node {
666                *node = map.resolve_dom_node_id(dom, n);
667            }
668        }
669    }
670}