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

            
27
use alloc::collections::btree_map::BTreeMap;
28
use alloc::vec::Vec;
29

            
30
use azul_core::dom::DomNodeId;
31
use azul_core::events::{
32
    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
33
};
34
use 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)]
45
pub 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)]
87
pub 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)]
103
pub 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

            
118
impl PermissionState {
119
    /// `true` if the capability is currently usable, regardless of quality.
120
20
    #[must_use] pub const fn is_granted(self) -> bool {
121
9
        matches!(
122
20
            self,
123
            Self::Granted(..) | Self::EphemeralGranted(..)
124
        )
125
20
    }
126

            
127
    /// `true` if a re-prompt could plausibly flip this to `Granted`.
128
15
    #[must_use] pub const fn could_re_prompt(self) -> bool {
129
15
        matches!(self, Self::NotDetermined)
130
15
    }
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)]
144
pub 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)]
169
pub struct CapabilityEntry {
170
    pub state: PermissionState,
171
    pub refcount: u32,
172
    pub last_subscriber: Option<DomNodeId>,
173
}
174

            
175
impl CapabilityEntry {
176
319
    const fn new() -> Self {
177
319
        Self {
178
319
            state: PermissionState::NotDetermined,
179
319
            refcount: 0,
180
319
            last_subscriber: None,
181
319
        }
182
319
    }
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)]
192
pub 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

            
208
impl 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
160
    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
214
160
        self.pending_changed
215
160
            .iter()
216
202
            .map(|(_capability, node)| {
217
128
                SyntheticEvent::new(
218
128
                    EventType::PermissionChanged,
219
128
                    CoreEventSource::User,
220
128
                    node.unwrap_or(DomNodeId::ROOT),
221
128
                    timestamp.clone(),
222
128
                    EventData::None,
223
                )
224
128
            })
225
160
            .collect()
226
160
    }
227
}
228

            
229
impl PermissionManager {
230
5840
    #[must_use] pub fn new() -> Self {
231
5840
        Self::default()
232
5840
    }
233

            
234
    /// Read the most recently observed state for `capability`.
235
2700
    #[must_use] pub fn get_status(&self, capability: Capability) -> PermissionState {
236
2700
        self.statuses
237
2700
            .get(&capability)
238
2700
            .map_or(PermissionState::NotDetermined, |e| e.state)
239
2700
    }
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
29
    pub fn subscribe(&mut self, capability: Capability, node_id: DomNodeId) {
245
29
        let entry = self
246
29
            .statuses
247
29
            .entry(capability)
248
29
            .or_insert_with(CapabilityEntry::new);
249
29
        entry.last_subscriber = Some(node_id);
250
29
        entry.refcount = entry.refcount.saturating_add(1);
251
29
        if entry.refcount == 1 {
252
23
            self.pending_events.push(PermissionDiffEvent::Subscribe {
253
23
                capability,
254
23
                node_id,
255
23
            });
256
23
        }
257
29
    }
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
36
    pub fn release(&mut self, capability: Capability) {
262
36
        let Some(entry) = self.statuses.get_mut(&capability) else {
263
18
            return;
264
        };
265
18
        if entry.refcount == 0 {
266
10
            return;
267
8
        }
268
8
        entry.refcount -= 1;
269
8
        if entry.refcount == 0 {
270
6
            entry.last_subscriber = None;
271
6
            self.pending_events
272
6
                .push(PermissionDiffEvent::Release { capability });
273
6
        }
274
36
    }
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
22
    pub fn force_release(&mut self, capability: Capability) {
280
22
        let Some(entry) = self.statuses.get_mut(&capability) else {
281
19
            return;
282
        };
283
3
        if entry.refcount == 0 {
284
1
            return;
285
2
        }
286
2
        entry.refcount = 0;
287
2
        entry.last_subscriber = None;
288
2
        self.pending_events
289
2
            .push(PermissionDiffEvent::Release { capability });
290
22
    }
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
349
    pub fn set_status(&mut self, capability: Capability, state: PermissionState) -> bool {
298
349
        let entry = self
299
349
            .statuses
300
349
            .entry(capability)
301
349
            .or_insert_with(CapabilityEntry::new);
302
349
        if entry.state == state {
303
39
            return false;
304
310
        }
305
310
        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
310
        self.pending_changed.push((capability, entry.last_subscriber));
309
310
        true
310
349
    }
311

            
312
    /// Clear the pending state-flip queue. The dll calls this after the
313
    /// event pass has collected the `PermissionChanged` events.
314
84
    pub fn clear_pending_changed(&mut self) {
315
84
        self.pending_changed.clear();
316
84
    }
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
78
    #[must_use] pub fn has_pending_async(&self) -> bool {
324
78
        self.statuses
325
78
            .values()
326
128
            .any(|e| e.state == PermissionState::Requested)
327
78
    }
328

            
329
    /// Drain queued diff events. Platform backend calls this once per frame.
330
55
    pub fn take_pending_events(&mut self) -> Vec<PermissionDiffEvent> {
331
55
        core::mem::take(&mut self.pending_events)
332
55
    }
333

            
334
    /// Refcount snapshot — primarily for diagnostics and tests.
335
81
    #[must_use] pub fn refcount(&self, capability: Capability) -> u32 {
336
81
        self.statuses
337
81
            .get(&capability)
338
81
            .map_or(0, |e| e.refcount)
339
81
    }
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
28
    pub fn diff_layout<F>(&mut self, mut for_each_bearing_node: F)
350
28
    where
351
28
        F: FnMut(&mut dyn FnMut(Capability, DomNodeId)),
352
    {
353
        // 1. Drain the new layout into (capability → (count, first_node)).
354
28
        let mut next: BTreeMap<Capability, (u32, Option<DomNodeId>)> = BTreeMap::new();
355
10047
        for_each_bearing_node(&mut |cap, node| {
356
10047
            let slot = next.entry(cap).or_insert((0, None));
357
10047
            slot.0 = slot.0.saturating_add(1);
358
10047
            if slot.1.is_none() {
359
46
                slot.1 = Some(node);
360
10001
            }
361
10047
        });
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
28
        let mut all_caps: Vec<Capability> = self.statuses.keys().copied().collect();
366
46
        for cap in next.keys() {
367
46
            if !all_caps.contains(cap) {
368
33
                all_caps.push(*cap);
369
33
            }
370
        }
371

            
372
120
        for cap in all_caps {
373
92
            let (new_count, first_node) = next.get(&cap).copied().unwrap_or((0, None));
374
92
            let entry = self
375
92
                .statuses
376
92
                .entry(cap)
377
92
                .or_insert_with(CapabilityEntry::new);
378
92
            let old_count = entry.refcount;
379
92
            entry.refcount = new_count;
380
92
            if new_count == 0 && old_count > 0 {
381
28
                entry.last_subscriber = None;
382
28
                self.pending_events
383
28
                    .push(PermissionDiffEvent::Release { capability: cap });
384
64
            } else if new_count > 0 && old_count == 0 {
385
35
                let node = first_node.unwrap_or(DomNodeId::ROOT);
386
35
                entry.last_subscriber = first_node;
387
35
                self.pending_events.push(PermissionDiffEvent::Subscribe {
388
35
                    capability: cap,
389
35
                    node_id: node,
390
35
                });
391
35
            }
392
        }
393
28
    }
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

            
407
static 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.
413
411
pub fn push_async_result(capability: Capability, state: PermissionState) {
414
411
    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
415
411
    q.push((capability, state));
416
411
}
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.
421
15
pub fn drain_async_results() -> Vec<(Capability, PermissionState)> {
422
15
    let mut q = ASYNC_RESULTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
423
15
    core::mem::take(&mut *q)
424
15
}
425

            
426
#[cfg(test)]
427
mod tests {
428
    use super::*;
429
    use azul_core::dom::{DomId, NodeId};
430

            
431
10
    fn node(idx: usize) -> DomNodeId {
432
10
        DomNodeId {
433
10
            dom: DomId::ROOT_ID,
434
10
            node: NodeId::from_usize(idx).into(),
435
10
        }
436
10
    }
437

            
438
    #[test]
439
1
    fn subscribe_release_round_trip_emits_paired_events() {
440
1
        let mut mgr = PermissionManager::new();
441
1
        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::NotDetermined);
442
1
        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
443

            
444
1
        mgr.subscribe(Capability::Geolocation, node(1));
445
1
        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
446
1
        let events = mgr.take_pending_events();
447
1
        assert_eq!(events.len(), 1);
448
1
        assert!(matches!(
449
1
            events[0],
450
            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
451
        ));
452

            
453
1
        mgr.release(Capability::Geolocation);
454
1
        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
455
1
        let events = mgr.take_pending_events();
456
1
        assert_eq!(events.len(), 1);
457
1
        assert!(matches!(
458
1
            events[0],
459
            PermissionDiffEvent::Release { capability: Capability::Geolocation }
460
        ));
461
1
    }
462

            
463
    #[test]
464
1
    fn second_subscriber_does_not_re_emit_subscribe() {
465
1
        let mut mgr = PermissionManager::new();
466
1
        mgr.subscribe(Capability::Camera, node(1));
467
1
        mgr.subscribe(Capability::Camera, node(2));
468
1
        assert_eq!(mgr.refcount(Capability::Camera), 2);
469
1
        let events = mgr.take_pending_events();
470
        // Exactly one Subscribe should have been emitted across both subscribes.
471
1
        assert_eq!(events.len(), 1);
472
1
    }
473

            
474
    #[test]
475
1
    fn release_only_after_last_subscriber_drops() {
476
1
        let mut mgr = PermissionManager::new();
477
1
        mgr.subscribe(Capability::Microphone, node(1));
478
1
        mgr.subscribe(Capability::Microphone, node(2));
479
        // Drain the initial Subscribe so the assertion below isolates Release.
480
1
        drop(mgr.take_pending_events());
481

            
482
1
        mgr.release(Capability::Microphone);
483
1
        assert_eq!(mgr.refcount(Capability::Microphone), 1);
484
1
        assert!(mgr.take_pending_events().is_empty());
485

            
486
1
        mgr.release(Capability::Microphone);
487
1
        assert_eq!(mgr.refcount(Capability::Microphone), 0);
488
1
        let events = mgr.take_pending_events();
489
1
        assert_eq!(events.len(), 1);
490
1
        assert!(matches!(
491
1
            events[0],
492
            PermissionDiffEvent::Release { capability: Capability::Microphone }
493
        ));
494
1
    }
495

            
496
    #[test]
497
1
    fn force_release_drops_refcount_and_emits_event() {
498
1
        let mut mgr = PermissionManager::new();
499
1
        mgr.subscribe(Capability::Camera, node(1));
500
1
        mgr.subscribe(Capability::Camera, node(2));
501
1
        drop(mgr.take_pending_events());
502

            
503
1
        mgr.force_release(Capability::Camera);
504
1
        assert_eq!(mgr.refcount(Capability::Camera), 0);
505
1
        let events = mgr.take_pending_events();
506
1
        assert_eq!(events.len(), 1);
507
1
        assert!(matches!(
508
1
            events[0],
509
            PermissionDiffEvent::Release { capability: Capability::Camera }
510
        ));
511
1
    }
512

            
513
    #[test]
514
1
    fn set_status_returns_change_flag() {
515
1
        let mut mgr = PermissionManager::new();
516
1
        assert!(mgr.set_status(Capability::Camera, PermissionState::Requested));
517
1
        assert!(!mgr.set_status(Capability::Camera, PermissionState::Requested));
518
1
        assert!(mgr.set_status(
519
1
            Capability::Camera,
520
1
            PermissionState::Granted(PermissionQuality::Full)
521
        ));
522
1
        assert!(mgr.get_status(Capability::Camera).is_granted());
523
1
    }
524

            
525
    #[test]
526
1
    fn diff_layout_picks_up_appearing_node_and_releases_it_next_frame() {
527
1
        let mut mgr = PermissionManager::new();
528

            
529
        // Frame 1: GeolocationProbe present.
530
1
        mgr.diff_layout(|emit| {
531
1
            emit(Capability::Geolocation, node(7));
532
1
        });
533
1
        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
534
1
        let events = mgr.take_pending_events();
535
1
        assert_eq!(events.len(), 1);
536
1
        assert!(matches!(
537
1
            events[0],
538
            PermissionDiffEvent::Subscribe { capability: Capability::Geolocation, .. }
539
        ));
540

            
541
        // Frame 2: probe removed.
542
1
        mgr.diff_layout(|_emit| { /* no bearing nodes this frame */ });
543
1
        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
544
1
        let events = mgr.take_pending_events();
545
1
        assert_eq!(events.len(), 1);
546
1
        assert!(matches!(
547
1
            events[0],
548
            PermissionDiffEvent::Release { capability: Capability::Geolocation }
549
        ));
550
1
    }
551

            
552
    #[test]
553
1
    fn diff_layout_re_emits_subscribe_after_release_cycle() {
554
1
        let mut mgr = PermissionManager::new();
555

            
556
1
        mgr.diff_layout(|emit| emit(Capability::Camera, node(1)));
557
1
        drop(mgr.take_pending_events());
558

            
559
1
        mgr.diff_layout(|_emit| {});
560
1
        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
1
        mgr.diff_layout(|emit| emit(Capability::Camera, node(2)));
565
1
        let events = mgr.take_pending_events();
566
1
        assert_eq!(events.len(), 1);
567
1
        assert!(matches!(
568
1
            events[0],
569
            PermissionDiffEvent::Subscribe { capability: Capability::Camera, .. }
570
        ));
571
1
    }
572

            
573
    #[test]
574
1
    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
1
        let _serialize = 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
1
        drop(drain_async_results());
581

            
582
1
        push_async_result(
583
1
            Capability::Camera,
584
1
            PermissionState::Granted(PermissionQuality::Full),
585
        );
586
1
        push_async_result(Capability::Geolocation, PermissionState::Denied);
587

            
588
1
        let drained = drain_async_results();
589
1
        assert_eq!(drained.len(), 2, "both parked results drain in order");
590
        // Arrival order preserved.
591
1
        assert_eq!(drained[0].0, Capability::Camera);
592
1
        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
1
        let mut mgr = PermissionManager::new();
597
3
        for (cap, state) in drained {
598
2
            mgr.set_status(cap, state);
599
2
        }
600
1
        assert!(mgr.get_status(Capability::Camera).is_granted());
601
1
        assert_eq!(mgr.get_status(Capability::Geolocation), PermissionState::Denied);
602

            
603
        // A second drain is empty — the queue was taken, not copied.
604
1
        assert!(drain_async_results().is_empty());
605
1
    }
606
}
607

            
608
#[cfg(test)]
609
mod pump_provider_tests {
610
    use super::*;
611
    use azul_core::task::{Instant, SystemTick};
612

            
613
5
    fn ts() -> Instant {
614
5
        Instant::Tick(SystemTick::new(0))
615
5
    }
616

            
617
    #[test]
618
1
    fn status_flip_yields_targeted_permission_changed_event() {
619
1
        let node = DomNodeId::ROOT;
620
1
        let mut mgr = PermissionManager::new();
621
1
        mgr.subscribe(Capability::Geolocation, node);
622
1
        drop(mgr.take_pending_events());
623
1
        assert!(mgr.get_pending_events(ts()).is_empty(), "no flip yet");
624

            
625
1
        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Requested));
626
1
        assert!(mgr.has_pending_async(), "Requested = OS prompt in flight");
627
1
        let events = mgr.get_pending_events(ts());
628
1
        assert_eq!(events.len(), 1);
629
1
        assert_eq!(
630
1
            events[0].event_type,
631
            EventType::PermissionChanged
632
        );
633
1
        assert_eq!(events[0].target, node, "targeted at the subscriber node");
634

            
635
1
        mgr.clear_pending_changed();
636
1
        assert!(mgr.get_pending_events(ts()).is_empty(), "cleared after dispatch");
637

            
638
1
        assert!(mgr.set_status(
639
1
            Capability::Geolocation,
640
1
            PermissionState::Granted(PermissionQuality::Full),
641
        ));
642
1
        assert!(!mgr.has_pending_async(), "prompt resolved");
643
1
        assert_eq!(mgr.get_pending_events(ts()).len(), 1);
644
1
    }
645

            
646
    #[test]
647
1
    fn unchanged_status_emits_no_event() {
648
1
        let mut mgr = PermissionManager::new();
649
1
        assert!(mgr.set_status(Capability::Geolocation, PermissionState::Denied));
650
1
        mgr.clear_pending_changed();
651
1
        assert!(!mgr.set_status(Capability::Geolocation, PermissionState::Denied));
652
1
        assert!(mgr.get_pending_events(ts()).is_empty());
653
1
    }
654
}
655

            
656
#[cfg(test)]
657
mod 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"
        );
    }
    #[test]
    fn subscribe_after_a_full_release_re_emits_subscribe() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Camera, node(1));
        mgr.release(Capability::Camera);
        drop(mgr.take_pending_events());
        // The platform tore the session down on Release, so the reappearing
        // node must produce a fresh Subscribe (0 -> 1 again).
        mgr.subscribe(Capability::Camera, node(2));
        let events = mgr.take_pending_events();
        assert_eq!(
            events,
            [PermissionDiffEvent::Subscribe {
                capability: Capability::Camera,
                node_id: node(2),
            }]
            .to_vec()
        );
    }
    #[test]
    fn release_cycle_preserves_the_os_observed_state() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Geolocation, node(1));
        mgr.set_status(
            Capability::Geolocation,
            PermissionState::Granted(PermissionQuality::Reduced),
        );
        mgr.release(Capability::Geolocation);
        // Refcount is gone but the grant is NOT forgotten — otherwise every
        // layout pass that drops the node would re-prompt the user.
        assert_eq!(mgr.refcount(Capability::Geolocation), 0);
        assert_eq!(
            mgr.get_status(Capability::Geolocation),
            PermissionState::Granted(PermissionQuality::Reduced)
        );
        assert_eq!(mgr.statuses[&Capability::Geolocation].last_subscriber, None);
    }
    #[test]
    fn subscribe_accepts_boundary_node_ids() {
        let mut mgr = PermissionManager::new();
        // The `None` sentinel (== DomNodeId::ROOT) and the largest encodable
        // NodeId must both round-trip through the event queue untouched.
        mgr.subscribe(Capability::Notifications, DomNodeId::ROOT);
        mgr.subscribe(Capability::Biometric, node(usize::MAX));
        let events = mgr.take_pending_events();
        assert_eq!(events.len(), 2);
        assert!(events.contains(&PermissionDiffEvent::Subscribe {
            capability: Capability::Notifications,
            node_id: DomNodeId::ROOT,
        }));
        assert!(events.contains(&PermissionDiffEvent::Subscribe {
            capability: Capability::Biometric,
            node_id: node(usize::MAX),
        }));
    }
    // ── set_status / pending_changed ────────────────────────────────────
    #[test]
    fn set_status_get_status_round_trips_over_the_whole_matrix() {
        for cap in ALL_CAPS {
            for state in ALL_STATES {
                let mut mgr = PermissionManager::new();
                mgr.set_status(cap, state);
                assert_eq!(mgr.get_status(cap), state, "{cap:?} / {state:?} did not round-trip");
                // Writing one capability must not leak into any other.
                for other in ALL_CAPS.iter().copied().filter(|c| *c != cap) {
                    assert_eq!(mgr.get_status(other), PermissionState::NotDetermined);
                }
            }
        }
    }
    #[test]
    fn set_status_change_flag_is_exact_over_every_transition() {
        for a in ALL_STATES {
            for b in ALL_STATES {
                let mut mgr = PermissionManager::new();
                let cap = Capability::Calendars;
                // The entry starts at NotDetermined, so writing NotDetermined
                // first is a no-change write.
                let changed_a = mgr.set_status(cap, a);
                assert_eq!(changed_a, a != PermissionState::NotDetermined, "{a:?}");
                let changed_b = mgr.set_status(cap, b);
                assert_eq!(changed_b, b != a, "{a:?} -> {b:?} reported the wrong flag");
                assert_eq!(mgr.get_status(cap), b);
                assert_eq!(mgr.has_pending_async(), b == PermissionState::Requested);
                // One PermissionChanged event per *actual* flip, no more.
                let expected = usize::from(changed_a) + usize::from(changed_b);
                assert_eq!(mgr.get_pending_events(ts(0)).len(), expected, "{a:?} -> {b:?}");
            }
        }
    }
    #[test]
    fn set_status_creates_an_entry_with_a_zero_refcount() {
        let mut mgr = PermissionManager::new();
        assert!(mgr.set_status(Capability::Reminders, PermissionState::Restricted));
        let entry = mgr.statuses[&Capability::Reminders];
        assert_eq!(entry.refcount, 0, "a status write is not a subscription");
        assert_eq!(entry.last_subscriber, None);
        assert!(mgr.take_pending_events().is_empty(), "set_status is not a diff event");
    }
    #[test]
    fn quality_and_payload_changes_count_as_state_changes() {
        let mut mgr = PermissionManager::new();
        let cap = Capability::PhotoLibrary;
        assert!(mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full)));
        // Full -> Reduced ("Selected Photos") is a real change the UI must see.
        assert!(mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Reduced)));
        assert!(!mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Reduced)));
        // Both EphemeralGranted payloads are distinct states.
        assert!(mgr.set_status(cap, PermissionState::EphemeralGranted(true)));
        assert!(mgr.set_status(cap, PermissionState::EphemeralGranted(false)));
        assert!(!mgr.set_status(cap, PermissionState::EphemeralGranted(false)));
        assert!(mgr.get_status(cap).is_granted());
    }
    #[test]
    fn has_pending_async_tracks_only_the_requested_state() {
        for state in ALL_STATES {
            let mut mgr = PermissionManager::new();
            mgr.set_status(Capability::Camera, state);
            assert_eq!(
                mgr.has_pending_async(),
                state == PermissionState::Requested,
                "has_pending_async({state:?})"
            );
        }
        // One in-flight prompt among many resolved capabilities still arms the
        // pump — otherwise the outcome never reaches callbacks in an idle app.
        let mut mgr = PermissionManager::new();
        for cap in ALL_CAPS {
            mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full));
        }
        assert!(!mgr.has_pending_async());
        mgr.set_status(Capability::AppTrackingTransparency, PermissionState::Requested);
        assert!(mgr.has_pending_async());
        mgr.set_status(Capability::AppTrackingTransparency, PermissionState::Denied);
        assert!(!mgr.has_pending_async());
    }
    #[test]
    fn pending_changed_targets_the_root_when_there_is_no_subscriber() {
        let mut mgr = PermissionManager::new();
        // A status flip with no bearing node (OS revoked it while nothing was
        // mounted) must still dispatch — targeted at the window root.
        mgr.set_status(Capability::Microphone, PermissionState::Denied);
        let events = mgr.get_pending_events(ts(42));
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event_type, EventType::PermissionChanged);
        assert_eq!(events[0].source, CoreEventSource::User);
        assert_eq!(events[0].target, DomNodeId::ROOT);
        assert_eq!(events[0].timestamp, ts(42), "the caller's timestamp is preserved");
    }
    #[test]
    fn pending_changed_falls_back_to_root_after_the_subscriber_leaves() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Microphone, node(4));
        mgr.set_status(Capability::Microphone, PermissionState::Requested);
        assert_eq!(mgr.get_pending_events(ts(0))[0].target, node(4));
        mgr.clear_pending_changed();
        // The node unmounts, then the OS answer lands: last_subscriber is
        // cleared, so the event must NOT point at a stale node index.
        mgr.release(Capability::Microphone);
        mgr.set_status(Capability::Microphone, PermissionState::Denied);
        let events = mgr.get_pending_events(ts(0));
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].target, DomNodeId::ROOT);
    }
    #[test]
    fn repeated_flips_accumulate_one_event_each_until_cleared() {
        let mut mgr = PermissionManager::new();
        let cap = Capability::LocalNetwork;
        mgr.set_status(cap, PermissionState::Requested);
        mgr.set_status(cap, PermissionState::Granted(PermissionQuality::Full));
        mgr.set_status(cap, PermissionState::Denied);
        assert_eq!(mgr.get_pending_events(ts(0)).len(), 3);
        // get_pending_events is a read, not a drain.
        assert_eq!(mgr.get_pending_events(ts(0)).len(), 3);
        mgr.clear_pending_changed();
        assert!(mgr.get_pending_events(ts(0)).is_empty());
        mgr.clear_pending_changed();
        assert!(mgr.get_pending_events(ts(0)).is_empty(), "clear is idempotent");
    }
    #[test]
    fn the_two_queues_drain_independently() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::NearbyWifi, node(1)); // -> pending_events
        mgr.set_status(Capability::NearbyWifi, PermissionState::Requested); // -> pending_changed
        // Clearing the state-flip queue must not swallow the diff events the
        // platform backend has not drained yet.
        mgr.clear_pending_changed();
        assert!(mgr.get_pending_events(ts(0)).is_empty());
        assert_eq!(mgr.take_pending_events().len(), 1);
        // ... and vice versa.
        mgr.set_status(Capability::NearbyWifi, PermissionState::Denied);
        mgr.subscribe(Capability::Contacts, node(2));
        assert_eq!(mgr.take_pending_events().len(), 1);
        assert!(mgr.take_pending_events().is_empty(), "take drains");
        assert_eq!(
            mgr.get_pending_events(ts(0)).len(),
            1,
            "take_pending_events must not clear pending_changed"
        );
    }
    // ── diff_layout ─────────────────────────────────────────────────────
    #[test]
    fn diff_layout_on_an_empty_manager_with_no_nodes_is_a_noop() {
        let mut mgr = PermissionManager::new();
        mgr.diff_layout(|_emit| {});
        assert!(mgr.statuses.is_empty());
        assert!(mgr.take_pending_events().is_empty());
        assert!(mgr.get_pending_events(ts(0)).is_empty());
    }
    #[test]
    fn diff_layout_counts_duplicates_and_anchors_the_first_node() {
        let mut mgr = PermissionManager::new();
        mgr.diff_layout(|emit| {
            emit(Capability::Camera, node(3));
            emit(Capability::Camera, node(4));
            emit(Capability::Camera, node(5));
        });
        assert_eq!(mgr.refcount(Capability::Camera), 3);
        assert_eq!(
            mgr.statuses[&Capability::Camera].last_subscriber,
            Some(node(3)),
            "the FIRST emitted node anchors the capability"
        );
        assert_eq!(
            mgr.take_pending_events(),
            [PermissionDiffEvent::Subscribe {
                capability: Capability::Camera,
                node_id: node(3),
            }]
            .to_vec(),
            "three bearing nodes still mean exactly one native subscribe"
        );
    }
    #[test]
    fn diff_layout_is_idempotent_for_a_stable_layout() {
        let mut mgr = PermissionManager::new();
        for _ in 0..5 {
            mgr.diff_layout(|emit| {
                emit(Capability::Camera, node(1));
                emit(Capability::Microphone, node(2));
            });
        }
        assert_eq!(mgr.refcount(Capability::Camera), 1);
        assert_eq!(mgr.refcount(Capability::Microphone), 1);
        assert_eq!(
            mgr.take_pending_events().len(),
            2,
            "an unchanged layout must not re-emit subscribes every frame"
        );
    }
    #[test]
    fn diff_layout_emits_release_and_subscribe_in_the_same_frame() {
        let mut mgr = PermissionManager::new();
        mgr.diff_layout(|emit| emit(Capability::Camera, node(1)));
        drop(mgr.take_pending_events());
        // The camera node is swapped for a geolocation node in one pass.
        mgr.diff_layout(|emit| emit(Capability::Geolocation, node(2)));
        let events = mgr.take_pending_events();
        assert_eq!(events.len(), 2, "{events:?}");
        assert!(events.contains(&PermissionDiffEvent::Release {
            capability: Capability::Camera
        }));
        assert!(events.contains(&PermissionDiffEvent::Subscribe {
            capability: Capability::Geolocation,
            node_id: node(2),
        }));
        assert_eq!(mgr.refcount(Capability::Camera), 0);
        assert_eq!(mgr.refcount(Capability::Geolocation), 1);
    }
    #[test]
    fn diff_layout_reconciles_a_manually_subscribed_capability() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Camera, node(1));
        mgr.subscribe(Capability::Camera, node(2));
        drop(mgr.take_pending_events());
        // The layout pass is authoritative: no bearing nodes this frame means
        // the refcount goes to 0 outright, not 2 -> 1.
        mgr.diff_layout(|_emit| {});
        assert_eq!(mgr.refcount(Capability::Camera), 0);
        assert_eq!(mgr.statuses[&Capability::Camera].last_subscriber, None);
        assert_eq!(
            mgr.take_pending_events(),
            [PermissionDiffEvent::Release {
                capability: Capability::Camera
            }]
            .to_vec()
        );
    }
    #[test]
    fn diff_layout_ignores_capabilities_that_only_have_a_status() {
        let mut mgr = PermissionManager::new();
        // set_status leaves a refcount-0 entry behind; diff_layout iterates
        // every known capability, so it must not emit a spurious Release.
        for cap in ALL_CAPS {
            mgr.set_status(cap, PermissionState::Denied);
        }
        mgr.diff_layout(|_emit| {});
        assert!(
            mgr.take_pending_events().is_empty(),
            "0 -> 0 is not a transition"
        );
        for cap in ALL_CAPS {
            assert_eq!(mgr.refcount(cap), 0);
            assert_eq!(mgr.get_status(cap), PermissionState::Denied);
        }
    }
    #[test]
    fn diff_layout_from_a_saturated_refcount_does_not_emit() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Motion, node(1));
        drop(mgr.take_pending_events());
        mgr.statuses.get_mut(&Capability::Motion).unwrap().refcount = u32::MAX;
        // MAX -> 1 is neither a 0 -> 1 nor an n -> 0 transition: the session
        // stays up and nothing is emitted.
        mgr.diff_layout(|emit| emit(Capability::Motion, node(1)));
        assert_eq!(mgr.refcount(Capability::Motion), 1);
        assert!(mgr.take_pending_events().is_empty());
    }
    #[test]
    fn diff_layout_handles_every_capability_at_once() {
        let mut mgr = PermissionManager::new();
        mgr.diff_layout(|emit| {
            for (i, cap) in ALL_CAPS.iter().enumerate() {
                emit(*cap, node(i + 1));
            }
        });
        let events = mgr.take_pending_events();
        assert_eq!(events.len(), ALL_CAPS.len(), "one Subscribe per capability");
        for (i, cap) in ALL_CAPS.iter().enumerate() {
            assert_eq!(mgr.refcount(*cap), 1);
            assert!(
                events.contains(&PermissionDiffEvent::Subscribe {
                    capability: *cap,
                    node_id: node(i + 1),
                }),
                "missing Subscribe for {cap:?}"
            );
        }
        // ... and tears them all down again.
        mgr.diff_layout(|_emit| {});
        assert_eq!(mgr.take_pending_events().len(), ALL_CAPS.len());
    }
    #[test]
    fn diff_layout_event_order_is_deterministic() {
        let run = || {
            let mut mgr = PermissionManager::new();
            mgr.diff_layout(|emit| {
                emit(Capability::Notifications, node(1));
                emit(Capability::Camera, node(2));
                emit(Capability::Geolocation, node(3));
            });
            let first = mgr.take_pending_events();
            mgr.diff_layout(|emit| emit(Capability::Camera, node(2)));
            let second = mgr.take_pending_events();
            (first, second)
        };
        assert_eq!(run(), run(), "the diff-event order must not depend on run order");
    }
    #[test]
    fn diff_layout_accepts_the_root_sentinel_as_a_bearing_node() {
        let mut mgr = PermissionManager::new();
        // A bearing node whose NodeHierarchyItemId is NONE encodes to exactly
        // the same value as the `unwrap_or(ROOT)` fallback — assert we still
        // subscribe rather than panicking or skipping it.
        mgr.diff_layout(|emit| emit(Capability::Biometric, DomNodeId::ROOT));
        assert_eq!(mgr.refcount(Capability::Biometric), 1);
        assert_eq!(
            mgr.take_pending_events(),
            [PermissionDiffEvent::Subscribe {
                capability: Capability::Biometric,
                node_id: DomNodeId::ROOT,
            }]
            .to_vec()
        );
    }
    #[test]
    fn diff_layout_handles_a_large_bearing_node_set() {
        let mut mgr = PermissionManager::new();
        const N: usize = 10_000;
        mgr.diff_layout(|emit| {
            for i in 1..=N {
                emit(Capability::PhotoLibraryWrite, node(i));
            }
        });
        assert_eq!(mgr.refcount(Capability::PhotoLibraryWrite), N as u32);
        assert_eq!(mgr.take_pending_events().len(), 1);
        mgr.diff_layout(|_emit| {});
        assert_eq!(mgr.refcount(Capability::PhotoLibraryWrite), 0);
        assert_eq!(mgr.take_pending_events().len(), 1);
    }
    #[test]
    fn diff_layout_does_not_disturb_the_state_machine() {
        let mut mgr = PermissionManager::new();
        mgr.set_status(
            Capability::Geolocation,
            PermissionState::EphemeralGranted(true),
        );
        mgr.clear_pending_changed();
        mgr.diff_layout(|emit| emit(Capability::Geolocation, node(1)));
        mgr.diff_layout(|_emit| {});
        // Subscribe/Release churn must never mutate the OS-observed state or
        // synthesize a PermissionChanged event out of thin air.
        assert_eq!(
            mgr.get_status(Capability::Geolocation),
            PermissionState::EphemeralGranted(true)
        );
        assert!(mgr.get_pending_events(ts(0)).is_empty());
    }
    // ── clone / equality ────────────────────────────────────────────────
    #[test]
    fn cloning_a_manager_deep_copies_its_queues() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Camera, node(1));
        mgr.set_status(Capability::Camera, PermissionState::Requested);
        let mut clone = mgr.clone();
        assert_eq!(clone, mgr);
        clone.subscribe(Capability::Camera, node(2));
        clone.force_release(Capability::Microphone);
        drop(clone.take_pending_events());
        clone.clear_pending_changed();
        assert_ne!(clone, mgr, "the clone shares state with the original");
        assert_eq!(mgr.refcount(Capability::Camera), 1);
        assert_eq!(mgr.take_pending_events().len(), 1, "original's queue was drained");
        assert_eq!(mgr.get_pending_events(ts(0)).len(), 1);
    }
    // ── NodeIdRemap ─────────────────────────────────────────────────────
    #[test]
    fn remap_rewrites_the_subscriber_and_the_queued_target() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Camera, node(3)); // node(3) == NodeId(2)
        mgr.set_status(Capability::Camera, PermissionState::Requested);
        // The DOM was rebuilt and the bearing node moved 2 -> 9.
        let map = NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(9))]);
        mgr.remap_node_ids(DomId::ROOT_ID, &map);
        assert_eq!(
            mgr.statuses[&Capability::Camera].last_subscriber,
            Some(node(10)), // node(10) == NodeId(9)
        );
        assert_eq!(mgr.get_pending_events(ts(0))[0].target, node(10));
    }
    #[test]
    fn remap_drops_an_unmounted_subscriber_instead_of_recycling_the_index() {
        let mut mgr = PermissionManager::new();
        mgr.subscribe(Capability::Camera, node(3));
        mgr.set_status(Capability::Camera, PermissionState::Requested);
        // Empty map == everything was unmounted.
        mgr.remap_node_ids(DomId::ROOT_ID, &NodeIdMap::default());
        assert_eq!(
            mgr.statuses[&Capability::Camera].last_subscriber,
            None,
            "an unmounted subscriber must fall back to None, never to a live-but-wrong node"
        );
        assert_eq!(
            mgr.get_pending_events(ts(0))[0].target,
            DomNodeId::ROOT,
            "the queued event retargets to the window root"
        );
        // The permission state itself survives the DOM rebuild.
        assert_eq!(mgr.get_status(Capability::Camera), PermissionState::Requested);
    }
    #[test]
    fn remap_leaves_nodes_from_other_doms_untouched() {
        let mut mgr = PermissionManager::new();
        let foreign = node_in_dom(7, 3);
        mgr.subscribe(Capability::Camera, foreign);
        mgr.set_status(Capability::Camera, PermissionState::Requested);
        // A reconciliation of DOM 0 says nothing about DOM 7 — dropping the
        // subscriber here would silently retarget an iframe's prompt at the
        // root window.
        mgr.remap_node_ids(DomId::ROOT_ID, &NodeIdMap::default());
        assert_eq!(
            mgr.statuses[&Capability::Camera].last_subscriber,
            Some(foreign)
        );
        assert_eq!(mgr.get_pending_events(ts(0))[0].target, foreign);
    }
    // ── async channel (process-global) ──────────────────────────────────
    #[test]
    fn async_channel_preserves_arrival_order_across_all_states() {
        let _serialize = lock_async_channel();
        drop(drain_async_results());
        for state in ALL_STATES {
            push_async_result(Capability::Camera, state);
        }
        let drained = drain_async_results();
        assert_eq!(drained.len(), ALL_STATES.len());
        for (i, state) in ALL_STATES.iter().enumerate() {
            assert_eq!(drained[i], (Capability::Camera, *state));
        }
        assert!(drain_async_results().is_empty(), "the queue is taken, not copied");
    }
    #[test]
    fn async_channel_recovers_from_a_poisoned_lock() {
        let _serialize = lock_async_channel();
        drop(drain_async_results());
        // Poison the global mutex the way a panicking applier would: unwind
        // out of a live guard. (The panic message below is expected output if
        // this test ever fails; libtest swallows it while it passes.)
        let unwound = std::panic::catch_unwind(|| {
            let _guard = ASYNC_RESULTS.lock().unwrap();
            panic!("intentional: poisoning ASYNC_RESULTS");
        });
        assert!(unwound.is_err(), "the panic must have unwound through the guard");
        assert!(ASYNC_RESULTS.is_poisoned(), "the lock should now be poisoned");
        // Documented contract: delivery keeps working after a poisoning.
        push_async_result(Capability::Geolocation, PermissionState::Denied);
        let drained = drain_async_results();
        assert_eq!(
            drained,
            [(Capability::Geolocation, PermissionState::Denied)].to_vec(),
            "a poisoned lock must not wedge permission delivery forever"
        );
        assert!(drain_async_results().is_empty());
    }
    #[test]
    fn async_channel_survives_concurrent_pushers() {
        let _serialize = lock_async_channel();
        drop(drain_async_results());
        const THREADS: usize = 8;
        const PER_THREAD: usize = 50;
        let handles: Vec<_> = (0..THREADS)
            .map(|t| {
                let cap = ALL_CAPS[t];
                std::thread::spawn(move || {
                    for _ in 0..PER_THREAD {
                        push_async_result(cap, PermissionState::Granted(PermissionQuality::Full));
                    }
                })
            })
            .collect();
        for h in handles {
            h.join().expect("a pusher thread panicked");
        }
        let drained = drain_async_results();
        assert_eq!(drained.len(), THREADS * PER_THREAD, "results were lost");
        for cap in ALL_CAPS.iter().take(THREADS) {
            let count = drained.iter().filter(|(c, _)| c == cap).count();
            assert_eq!(count, PER_THREAD, "{cap:?} lost results");
        }
        assert!(drain_async_results().is_empty());
    }
    #[test]
    fn draining_an_empty_async_channel_is_safe_and_repeatable() {
        let _serialize = lock_async_channel();
        for _ in 0..3 {
            assert!(drain_async_results().is_empty());
        }
    }
}
impl crate::managers::NodeIdRemap for PermissionManager {
    /// Remap the `last_subscriber` node of each capability (the node a
    /// `PermissionChanged` event is targeted at) and the queued
    /// `pending_changed` targets. An unmounted subscriber falls back to `None`
    /// (→ the event targets the window root), never to a recycled index.
27
    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
27
        for entry in self.statuses.values_mut() {
3
            if let Some(node) = entry.last_subscriber {
3
                entry.last_subscriber = map.resolve_dom_node_id(dom, node);
3
            }
        }
30
        for (_capability, node) in &mut self.pending_changed {
3
            if let Some(n) = *node {
3
                *node = map.resolve_dom_node_id(dom, n);
3
            }
        }
27
    }
}