Skip to main content

oxide_browser/
permissions.rs

1//! Per-origin permission model for sensitive host APIs.
2//!
3//! Sensitive capabilities (camera, microphone, geolocation, screen capture) are not granted
4//! automatically. The first time an app requests one, the host queues a [`PendingPermission`]
5//! and the UI shell renders a Chrome-style prompt (top-left, under the toolbar). Until the
6//! user decides, the gated API returns a "pending" code ([`PERMISSION_PENDING`] for `i32`
7//! APIs) and the guest is expected to retry on a later frame.
8//!
9//! Decisions are remembered per `(origin, kind)` pair for the lifetime of the tab, where
10//! *origin* is derived via [`crate::url::app_origin_of`] (scheme + host + port for network
11//! URLs, the containing directory for `file://` URLs).
12//!
13//! This flow is deliberately non-blocking: guest frame callbacks run on the UI thread, so a
14//! modal prompt inside a host function would deadlock the renderer.
15
16use std::collections::HashMap;
17use std::sync::{Arc, Mutex};
18
19/// Return code for permission-gated `i32` host APIs while the prompt is awaiting a decision.
20///
21/// Guests should treat this as "try again next frame", not as a hard failure. Mirrored as
22/// `oxide_sdk::PERMISSION_PENDING`.
23pub const PERMISSION_PENDING: i32 = -5;
24
25/// A sensitive capability that requires an explicit user grant.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum PermissionKind {
28    Camera,
29    Microphone,
30    Geolocation,
31    ScreenCapture,
32}
33
34impl PermissionKind {
35    /// Human-readable request line shown in the permission prompt.
36    pub fn description(&self) -> &'static str {
37        match self {
38            PermissionKind::Camera => "Use your camera",
39            PermissionKind::Microphone => "Use your microphone",
40            PermissionKind::Geolocation => "Know your location",
41            PermissionKind::ScreenCapture => "See your screen",
42        }
43    }
44
45    /// Stable identifier used in manifests and logs.
46    pub fn name(&self) -> &'static str {
47        match self {
48            PermissionKind::Camera => "camera",
49            PermissionKind::Microphone => "microphone",
50            PermissionKind::Geolocation => "geolocation",
51            PermissionKind::ScreenCapture => "screen-capture",
52        }
53    }
54}
55
56/// Outcome of a permission check from a host function.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum PermissionStatus {
59    /// The user allowed this `(origin, kind)`; proceed.
60    Granted,
61    /// The user blocked this `(origin, kind)`; fail the call.
62    Denied,
63    /// A prompt is showing (or queued); the guest should retry on a later frame.
64    Pending,
65}
66
67/// A permission request awaiting a user decision, rendered by the UI shell.
68#[derive(Clone, Debug)]
69pub struct PendingPermission {
70    /// App origin making the request (see [`crate::url::app_origin_of`]).
71    pub origin: String,
72    /// Capability being requested.
73    pub kind: PermissionKind,
74}
75
76/// Per-tab permission decisions plus the prompt currently awaiting the user.
77#[derive(Default)]
78pub struct PermissionsState {
79    /// `(origin, kind)` → allowed. Absent means "not yet asked".
80    decisions: HashMap<(String, PermissionKind), bool>,
81    /// At most one prompt is shown at a time; further requests stay [`PermissionStatus::Pending`]
82    /// and re-queue themselves on retry once this one resolves.
83    pub pending: Option<PendingPermission>,
84}
85
86/// Shared handle stored in `HostState` and read by the UI shell each frame.
87pub type SharedPermissions = Arc<Mutex<PermissionsState>>;
88
89/// Looks up the decision for `(origin, kind)`, queueing a prompt when undecided.
90///
91/// Returns [`PermissionStatus::Pending`] both when this request becomes the active prompt and
92/// when another prompt is already showing (the retry will queue it once the slot frees up).
93pub fn check_or_request(
94    perms: &SharedPermissions,
95    origin: &str,
96    kind: PermissionKind,
97) -> PermissionStatus {
98    let mut state = perms.lock().unwrap();
99    if let Some(&allowed) = state.decisions.get(&(origin.to_string(), kind)) {
100        return if allowed {
101            PermissionStatus::Granted
102        } else {
103            PermissionStatus::Denied
104        };
105    }
106    if state.pending.is_none() {
107        state.pending = Some(PendingPermission {
108            origin: origin.to_string(),
109            kind,
110        });
111    }
112    PermissionStatus::Pending
113}
114
115/// Records the user's decision for the active prompt and dismisses it.
116///
117/// Called by the UI shell when Allow/Block is clicked. No-op when nothing is pending.
118pub fn resolve_pending(perms: &SharedPermissions, allow: bool) {
119    let mut state = perms.lock().unwrap();
120    if let Some(req) = state.pending.take() {
121        state.decisions.insert((req.origin, req.kind), allow);
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn shared() -> SharedPermissions {
130        Arc::new(Mutex::new(PermissionsState::default()))
131    }
132
133    #[test]
134    fn first_request_is_pending_and_queues_prompt() {
135        let perms = shared();
136        let status = check_or_request(&perms, "https://a.com", PermissionKind::Camera);
137        assert_eq!(status, PermissionStatus::Pending);
138        let state = perms.lock().unwrap();
139        let pending = state.pending.as_ref().expect("prompt queued");
140        assert_eq!(pending.origin, "https://a.com");
141        assert_eq!(pending.kind, PermissionKind::Camera);
142    }
143
144    #[test]
145    fn allow_then_granted() {
146        let perms = shared();
147        check_or_request(&perms, "https://a.com", PermissionKind::Camera);
148        resolve_pending(&perms, true);
149        assert_eq!(
150            check_or_request(&perms, "https://a.com", PermissionKind::Camera),
151            PermissionStatus::Granted
152        );
153    }
154
155    #[test]
156    fn block_then_denied() {
157        let perms = shared();
158        check_or_request(&perms, "https://a.com", PermissionKind::Microphone);
159        resolve_pending(&perms, false);
160        assert_eq!(
161            check_or_request(&perms, "https://a.com", PermissionKind::Microphone),
162            PermissionStatus::Denied
163        );
164    }
165
166    #[test]
167    fn decisions_are_scoped_per_origin_and_kind() {
168        let perms = shared();
169        check_or_request(&perms, "https://a.com", PermissionKind::Camera);
170        resolve_pending(&perms, true);
171        // Same origin, different kind: must prompt again.
172        assert_eq!(
173            check_or_request(&perms, "https://a.com", PermissionKind::Microphone),
174            PermissionStatus::Pending
175        );
176        resolve_pending(&perms, false);
177        // Different origin, same kind: must prompt again.
178        assert_eq!(
179            check_or_request(&perms, "https://b.com", PermissionKind::Camera),
180            PermissionStatus::Pending
181        );
182    }
183
184    #[test]
185    fn second_request_waits_for_active_prompt() {
186        let perms = shared();
187        check_or_request(&perms, "https://a.com", PermissionKind::Camera);
188        // Another kind requested while the camera prompt is up: stays pending, not queued.
189        assert_eq!(
190            check_or_request(&perms, "https://a.com", PermissionKind::Geolocation),
191            PermissionStatus::Pending
192        );
193        assert_eq!(
194            perms.lock().unwrap().pending.as_ref().unwrap().kind,
195            PermissionKind::Camera
196        );
197        resolve_pending(&perms, true);
198        // Retry now queues the geolocation prompt.
199        assert_eq!(
200            check_or_request(&perms, "https://a.com", PermissionKind::Geolocation),
201            PermissionStatus::Pending
202        );
203        assert_eq!(
204            perms.lock().unwrap().pending.as_ref().unwrap().kind,
205            PermissionKind::Geolocation
206        );
207    }
208
209    #[test]
210    fn resolve_without_pending_is_noop() {
211        let perms = shared();
212        resolve_pending(&perms, true);
213        assert!(perms.lock().unwrap().pending.is_none());
214    }
215}