Skip to main content

orinium_browser/browser/core/
app.rs

1//! Browser core: application entry and lifecycle manager.
2//!
3//! Responsibilities:
4//! - Manage the window collection and each window's [`BrowserUi`].
5//! - Forward winit window events to the owning window's UI.
6//! - Coordinate network/resource loading and route responses to the owning window.
7//!
8//! Tab state, input handling, and rendering are delegated to [`BrowserUi`] /
9//! [`BrowserRenderer`]; this type stays a thin orchestrator.
10//!
11//! Processing flow (high-level):
12//! 1. Initialize platform components (system window, GPU renderer, network core).
13//! 2. Create and register `BrowserUi` instances and navigate to initial URLs.
14//! 3. Enter event loop: forward events -> delegate to `BrowserUi` -> route fetches.
15//!
16//! Example (for contributors / local testing):
17//! ```no_run
18//! use orinium_browser::browser::{BrowserApp, BrowserUi, Tab};
19//!
20//! let mut tab = Tab::default();
21//! tab.navigate("resource:///test/test.html".parse().unwrap());
22//! let mut app = BrowserApp::default();
23//! app.set_default_ui(BrowserUi::with_tab(tab));
24//! app.run().unwrap();
25//! ```
26//!
27//! Developer notes:
28//! - For parsing and layout details see `engine::html`, `engine::css`, and `engine::layouter`.
29//! - For platform integration see `platform::{network, renderer, system}`.
30//! - Keep public API small and document invariants for Tab lifecycle and fetch handling.
31
32use anyhow::Result;
33use std::collections::{HashMap, hash_map::DefaultHasher};
34use std::hash::{Hash, Hasher};
35use std::time::{SystemTime, UNIX_EPOCH};
36use std::{env, io};
37use url::Url;
38use winit::event::WindowEvent;
39use winit::window::WindowId;
40
41use super::tab::FetchKind;
42use super::ui::{BrowserUi, FetchParams};
43use super::{BrowserCommand, resource_loader::BrowserResourceLoader};
44use crate::browser::core::ui::TabId;
45use crate::engine::origin::Origin;
46use crate::engine::permissions::Permissions;
47use crate::platform::network::{NetworkCore, NetworkRequest};
48use crate::platform::renderer::draw_sink::DrawSink;
49use crate::platform::system::App;
50use crate::platform::system::shell::{BrowserHost, ShellCommand, WindowGeometry};
51
52/// A resource fetch in flight from a tab.
53pub(crate) struct PendingFetch {
54    window_id: WindowId,
55    tab_id: TabId,
56    kind: FetchKind,
57    url: Url,
58    generation: u64,
59    /// The origin of the frame that requested the resource.
60    origin: Origin,
61    /// The effective capabilities of the requesting frame, retained so the
62    /// response can be handed back to the correct frame.
63    permissions: Permissions,
64}
65
66pub(crate) struct PendingFetches {
67    /// Maps generated fetch ids to the request they belong to.
68    map: HashMap<usize, PendingFetch>,
69    counter: usize,
70}
71
72impl PendingFetches {
73    pub fn new() -> Self {
74        Self {
75            map: HashMap::new(),
76            counter: 0,
77        }
78    }
79    /// URLとFetchKindを受け取り、一意IDを生成して登録
80    #[allow(clippy::too_many_arguments)]
81    pub fn insert(
82        &mut self,
83        window_id: WindowId,
84        tab_id: TabId,
85        kind: FetchKind,
86        url: Url,
87        generation: u64,
88        origin: Origin,
89        permissions: Permissions,
90    ) -> usize {
91        self.counter += 1;
92
93        let id = self.generate_id(&url);
94
95        self.map.insert(
96            id,
97            PendingFetch {
98                window_id,
99                tab_id,
100                kind,
101                url,
102                generation,
103                origin,
104                permissions,
105            },
106        );
107        id
108    }
109
110    fn generate_id(&self, url: &Url) -> usize {
111        // URLをハッシュ化
112        let mut hasher = DefaultHasher::new();
113        url.hash(&mut hasher);
114        let url_hash = hasher.finish() as usize;
115
116        // 現在時刻ナノ秒
117        let now = SystemTime::now()
118            .duration_since(UNIX_EPOCH)
119            .expect("Time went backwards")
120            .as_nanos() as usize;
121
122        // ナノ秒 XOR カウンタ XOR URLハッシュ
123        now ^ self.counter ^ url_hash
124    }
125
126    pub fn remove(&mut self, id: usize) -> Option<PendingFetch> {
127        self.map.remove(&id)
128    }
129}
130
131/// Whether a fetch result captured at `fetch_generation` is stale for a tab
132/// currently at `current_generation`.
133///
134/// A tab's generation is bumped on every navigation, so a result produced for a
135/// previous document must be dropped rather than delivered to the new one. This
136/// is a pure function so the decision can be tested directly, and kept as the
137/// single place the staleness rule lives.
138fn fetch_is_stale(current_generation: u64, fetch_generation: u64) -> bool {
139    current_generation != fetch_generation
140}
141
142/// Main browser application struct.
143///
144/// Responsibilities:
145/// - Manage the window collection and per-window [`BrowserUi`] instances.
146/// - Forward winit window events to each window's UI.
147/// - Coordinate resource loading and route fetched results to the owning window.
148///
149/// Tab state, input handling, and rendering are delegated to [`BrowserUi`] /
150/// [`BrowserRenderer`].
151///
152/// Typical lifecycle:
153/// 1. Construct `BrowserApp::new(...)`, which wires platform components (network, system).
154/// 2. Create `Tab` objects, wrap them in a `BrowserUi`, and call `set_default_ui`.
155/// 3. Call `run()` to start the event loop. Each loop iteration:
156///    - Forward winit events to the owning window's `BrowserUi`.
157///    - Tick the UI, forward fetch requests to the network, and route responses back.
158///
159/// Example usage:
160/// ```no_run
161/// use orinium_browser::browser::{BrowserApp, BrowserUi, Tab};
162///
163/// let mut tab = Tab::default();
164/// tab.navigate("resource:///test/test.html".parse().unwrap());
165/// let mut app = BrowserApp::default();
166/// app.set_default_ui(BrowserUi::with_tab(tab));
167/// app.run().unwrap();
168/// ```
169pub struct BrowserApp {
170    /// Maps each window to its UI (tabs, input state, renderer).
171    windows: HashMap<WindowId, BrowserUi>,
172    /// Default window size used when opening a new window.
173    default_window_size: (u32, u32),
174    /// Default window title used when opening a new window.
175    default_window_title: String,
176    network: BrowserResourceLoader,
177    pending_fetches: PendingFetches,
178    /// UI used when the first window opens (if set before `run()`).
179    default_ui: Option<BrowserUi>,
180}
181
182impl Default for BrowserApp {
183    fn default() -> Self {
184        Self::new((1280, 800), "Orinium Browser".to_string()).unwrap()
185    }
186}
187
188impl BrowserApp {
189    /// Starts the main browser event loop asynchronously.
190    /// Returns an error if no default UI was set via `set_default_ui`.
191    pub fn run(self) -> Result<()> {
192        if self.default_ui.is_none() {
193            anyhow::bail!("set_default_ui must be called before run()");
194        }
195        run_with_winit_backend(self)
196    }
197
198    /// Creates a new browser instance with the given default window size and title.
199    /// Windows are registered later via `open_window`.
200    pub fn new(
201        default_window_size: (u32, u32),
202        default_window_title: String,
203    ) -> Result<Self, io::Error> {
204        let network = BrowserResourceLoader::with_network(NetworkCore::new()?);
205
206        Ok(Self {
207            windows: HashMap::new(),
208            default_window_size,
209            default_window_title,
210            network,
211            pending_fetches: PendingFetches::new(),
212            default_ui: None,
213        })
214    }
215
216    /// Opens a window showing a caller-supplied UI.
217    ///
218    /// This is the in-process entry point used by embedders that build one UI
219    /// per window. The [`BrowserHost`] port deliberately offers no such hook:
220    /// the OS shell only knows how to describe a window, never what to put in
221    /// it.
222    pub fn open_window_with_ui(
223        &mut self,
224        window_id: WindowId,
225        geometry: WindowGeometry,
226        root_ui: BrowserUi,
227    ) {
228        self.register_window(window_id, &geometry, root_ui);
229    }
230
231    /// Registers a window with its per-window UI.
232    fn register_window(
233        &mut self,
234        window_id: WindowId,
235        geometry: &WindowGeometry,
236        mut root_ui: BrowserUi,
237    ) {
238        root_ui.set_window(geometry.size, geometry.scale_factor, geometry.title.clone());
239        self.windows.insert(window_id, root_ui);
240    }
241
242    /// Removes a window's state when the window is closed.
243    pub fn close_window(&mut self, window_id: WindowId) {
244        self.windows.remove(&window_id);
245    }
246
247    /// Sets the UI to use when the first window opens.
248    /// Must be called before `run()`.
249    pub fn set_default_ui(&mut self, ui: BrowserUi) {
250        self.default_ui = Some(ui);
251    }
252
253    /// Handles a `winit` window event for the given window and returns a `BrowserCommand`.
254    pub fn handle_window_event(
255        &mut self,
256        window_id: WindowId,
257        event: WindowEvent,
258        sink: &mut dyn DrawSink,
259    ) -> BrowserCommand {
260        let browser_cmd = match self.windows.get_mut(&window_id) {
261            Some(ui) => ui.handle_window_event(event, sink),
262            None => BrowserCommand::None,
263        };
264        let cmd_from_tick = self.tick(window_id);
265        match browser_cmd {
266            BrowserCommand::None => {
267                if matches!(cmd_from_tick, BrowserCommand::RequestRedraw) {
268                    self.redraw(window_id, sink);
269                }
270                cmd_from_tick
271            }
272            BrowserCommand::RenameWindowTitle => {
273                if matches!(cmd_from_tick, BrowserCommand::RequestRedraw) {
274                    // tick() が追加の処理を要求 → RequestRedraw に昇格させる。
275                    // RequestRedraw のハンドラはタイトル設定も行うので情報は失われない。
276                    self.redraw(window_id, sink);
277                    BrowserCommand::RequestRedraw
278                } else {
279                    browser_cmd
280                }
281            }
282            _ => {
283                if matches!(cmd_from_tick, BrowserCommand::RequestRedraw) {
284                    self.redraw(window_id, sink);
285                }
286                browser_cmd
287            }
288        }
289    }
290
291    /// Rebuilds the render tree and sends draw commands to the GPU for the given window.
292    pub fn redraw(&mut self, window_id: WindowId, sink: &mut dyn DrawSink) {
293        let Some(ui) = self.windows.get_mut(&window_id) else {
294            return;
295        };
296        ui.redraw(sink);
297    }
298
299    /// Applies the current draw commands for the given window to the GPU renderer.
300    pub fn apply_draw_commands(&self, window_id: WindowId, sink: &mut dyn DrawSink) {
301        if let Some(ui) = self.windows.get(&window_id) {
302            ui.apply_draw_commands(sink);
303        }
304    }
305
306    /// Advances background page work for a window between OS events.
307    ///
308    /// This keeps animated custom controls, such as an active audio timer,
309    /// repainting even when the user is not moving the pointer.
310    pub(crate) fn poll_window(&mut self, window_id: WindowId) -> bool {
311        matches!(self.tick(window_id), BrowserCommand::RequestRedraw)
312    }
313
314    /// Returns the current window size for the given window as `(width, height)` in floating-point pixels.
315    pub fn window_size(&self, window_id: WindowId) -> (f32, f32) {
316        match self.windows.get(&window_id) {
317            Some(ui) => {
318                let (width, height) = ui.window_size();
319                (width as f32, height as f32)
320            }
321            None => (
322                self.default_window_size.0 as f32,
323                self.default_window_size.1 as f32,
324            ),
325        }
326    }
327
328    /// Returns the window title for the given window.
329    pub fn window_title(&self, window_id: WindowId) -> String {
330        match self.windows.get(&window_id) {
331            Some(ui) => ui.window_title(),
332            None => self.default_window_title.clone(),
333        }
334    }
335
336    /// Ticks the given window's UI and forwards its fetch requests to the network.
337    fn tick(&mut self, window_id: WindowId) -> BrowserCommand {
338        self.handle_network_messages();
339
340        let Some(ui) = self.windows.get_mut(&window_id) else {
341            return BrowserCommand::None;
342        };
343        let outcome = ui.tick();
344
345        for fetch in outcome.fetches {
346            let url = fetch.request.url;
347            let kind = fetch.request.kind;
348            let initiator = fetch.request.origin;
349            let permissions = fetch.request.permissions;
350            log::info!("Fetch requested in App: url={}", url);
351            let mut request = match &kind {
352                FetchKind::JavaScript {
353                    method,
354                    headers,
355                    body,
356                    ..
357                } => NetworkRequest {
358                    url: url.to_string(),
359                    method: method.clone(),
360                    headers: headers.clone(),
361                    body: body.clone(),
362                },
363                _ => NetworkRequest::get(url.to_string()),
364            };
365            // Add browser-controlled Origin / Referer headers and strip any the
366            // page script supplied, so internal resource URLs never leak to
367            // external servers.
368            apply_fetch_headers(&mut request, &initiator, &kind);
369            let id = self.pending_fetches.insert(
370                window_id,
371                fetch.tab_id,
372                kind,
373                url,
374                fetch.request.generation,
375                initiator.clone(),
376                permissions,
377            );
378            // The frame's permissions gate access to internal schemes inside the
379            // loader. We also track the originating document generation so stale
380            // results from an earlier navigation are dropped when they arrive.
381            self.network
382                .fetch_request_async(request, id, &initiator, permissions);
383        }
384
385        if outcome.needs_redraw {
386            BrowserCommand::RequestRedraw
387        } else {
388            BrowserCommand::None
389        }
390    }
391
392    fn handle_network_messages(&mut self) {
393        let messages = self.network.try_receive();
394
395        for msg in messages {
396            log::info!("Network message received in App for fetch_id={}", msg.id);
397
398            // pending_fetches から fetch 情報を取得
399            let Some(pending) = self.pending_fetches.remove(msg.id) else {
400                log::warn!("No pending fetch found for fetch_id={}", msg.id);
401                continue;
402            };
403            let PendingFetch {
404                window_id,
405                tab_id,
406                kind,
407                url,
408                generation,
409                origin,
410                permissions,
411            } = pending;
412
413            let Some(ui) = self.windows.get_mut(&window_id) else {
414                log::warn!("There is no window called id={:?}", window_id);
415                continue;
416            };
417            if tab_id.0 != 0 {
418                let Some(tab) = ui.tab(&tab_id) else {
419                    log::warn!("Tab was closed before fetch completed: tab_id={}", tab_id);
420                    continue;
421                };
422                let current_generation = tab.document_generation();
423                if fetch_is_stale(current_generation, generation) {
424                    log::info!(
425                        "Dropping stale fetch result for tab_id={} generation={} (current={})",
426                        tab_id,
427                        generation,
428                        current_generation
429                    );
430                    continue;
431                }
432            }
433            ui.deliver_fetch(
434                &tab_id,
435                FetchParams {
436                    kind,
437                    url,
438                    origin,
439                    permissions,
440                    response: msg.response,
441                    generation,
442                },
443            );
444        }
445    }
446}
447
448/// Bridges the browser core to the OS shell.
449///
450/// The trait lives in [`crate::platform::system::shell`] so the event loop can
451/// be driven by any host without naming a browser type.
452impl BrowserHost for BrowserApp {
453    fn default_window_size(&self) -> (u32, u32) {
454        self.default_window_size
455    }
456
457    fn default_window_title(&self) -> String {
458        self.default_window_title.clone()
459    }
460
461    fn open_window(&mut self, window_id: WindowId, geometry: WindowGeometry) {
462        // The first window shows the UI handed to `set_default_ui`; any later
463        // window (Ctrl+N) starts empty.
464        let root_ui = self
465            .default_ui
466            .take()
467            .unwrap_or_else(|| BrowserUi::with_tab(super::Tab::default()));
468        self.open_window_with_ui(window_id, geometry, root_ui);
469    }
470
471    fn has_pending_default_window(&self) -> bool {
472        self.default_ui.is_some()
473    }
474
475    fn close_window(&mut self, window_id: WindowId) {
476        self.windows.remove(&window_id);
477    }
478
479    fn window_title(&self, window_id: WindowId) -> String {
480        match self.windows.get(&window_id) {
481            Some(ui) => ui.window_title(),
482            None => self.default_window_title.clone(),
483        }
484    }
485
486    fn handle_window_event(
487        &mut self,
488        window_id: WindowId,
489        event: WindowEvent,
490        sink: &mut dyn DrawSink,
491    ) -> ShellCommand {
492        BrowserApp::handle_window_event(self, window_id, event, sink)
493    }
494
495    fn apply_draw_commands(&mut self, window_id: WindowId, sink: &mut dyn DrawSink) {
496        BrowserApp::apply_draw_commands(self, window_id, sink);
497    }
498
499    fn poll_window(&mut self, window_id: WindowId) -> bool {
500        self.poll_window(window_id)
501    }
502}
503
504/// Applies the browser-controlled `Origin` / `Referer` request headers.
505///
506/// - Any `Origin` / `Referer` supplied by page scripts is stripped.
507/// - An `Origin` header is sent for CORS-mode requests (`fetch`/`XMLHttpRequest`)
508///   and for non-GET requests; the value is the initiator's serialized origin
509///   (`"null"` for opaque/internal pages), as required by the Fetch standard.
510/// - A `Referer` origin-only header is sent for network origins only; opaque
511///   pages never send one, so bundled `resource:` URLs cannot be leaked.
512fn apply_fetch_headers(request: &mut NetworkRequest, initiator: &Origin, kind: &FetchKind) {
513    request.headers.retain(|(name, _)| {
514        let name = name.to_ascii_lowercase();
515        name != "origin" && name != "referer"
516    });
517
518    if !request.url.starts_with("http://") && !request.url.starts_with("https://") {
519        return;
520    }
521
522    let is_cors_request = matches!(kind, FetchKind::JavaScript { .. }) || request.method != "GET";
523    if is_cors_request {
524        request
525            .headers
526            .push(("Origin".to_string(), initiator.ascii_serialization()));
527    }
528    if initiator.is_network() {
529        request
530            .headers
531            .push(("Referer".to_string(), initiator.ascii_serialization()));
532    }
533}
534
535fn run_with_winit_backend(app: BrowserApp) -> Result<()> {
536    configure_winit_backend_for_wslg();
537    if env::var_os("ORINIUM_FORCE_X11").is_some() {
538        configure_winit_backend_forced_x11();
539    }
540
541    run_event_loop(app)
542}
543
544fn run_event_loop(app: BrowserApp) -> Result<()> {
545    let event_loop = winit::event_loop::EventLoop::new()?;
546    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
547    let mut app = App::new(Box::new(app));
548    event_loop.run_app(&mut app)?;
549    Ok(())
550}
551
552fn configure_winit_backend_forced_x11() {
553    let current = env::var("WINIT_UNIX_BACKEND").ok();
554    let should_force_x11 = !matches!(current.as_deref(), Some("x11"));
555
556    if should_force_x11 {
557        unsafe {
558            env::set_var("WINIT_UNIX_BACKEND", "x11");
559            env::remove_var("WAYLAND_DISPLAY");
560        }
561        log::info!("Forcing X11 (WINIT_UNIX_BACKEND=x11, WAYLAND_DISPLAY cleared)");
562    }
563}
564
565fn configure_winit_backend_for_wslg() {
566    let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some() || env::var_os("WSL_INTEROP").is_some();
567    if !is_wsl {
568        return;
569    }
570
571    // On WSLg, Wayland is often unstable; default to X11 unless explicitly requested.
572    if env::var_os("ORINIUM_PREFER_WAYLAND").is_some() {
573        return;
574    }
575
576    let current = env::var("WINIT_UNIX_BACKEND").ok();
577    let should_force_x11 = !matches!(current.as_deref(), Some("x11"));
578
579    if should_force_x11 {
580        unsafe {
581            env::set_var("WINIT_UNIX_BACKEND", "x11");
582            env::remove_var("WAYLAND_DISPLAY");
583        }
584        log::info!("WSLg detected: defaulting to X11 backend for stability");
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::test_support::assert_send;
592
593    /// Pins the `Send` bound reached so far, and documents what still blocks
594    /// `BrowserApp` from becoming `Send`.
595    ///
596    /// Everything the resource path owns is movable now that
597    /// [`BrowserResourceLoader`] holds its `NetworkCore` outright instead of
598    /// behind an `Rc`. The remaining blockers are all page-boundary state:
599    ///
600    /// - `Box<dyn Chrome>` / `Box<dyn ContextMenu>` (UI trait objects that
601    ///   predate the layering fix)
602    /// - `ui_layout::CustomLayouter` (reached through `LayoutNode`, and today
603    ///   sidestepped by `SendableResult`'s raw pointer)
604    /// - `Rc<DomTree>` and `Weak<RefCell<TreeNode<HtmlNodeType>>>` (the live
605    ///   DOM the browser layer still mutates directly)
606    ///
607    /// Add assertions here as each group is removed, so the progress is
608    /// visible and the bound can never silently regress.
609    #[test]
610    fn resource_path_is_send() {
611        assert_send::<BrowserResourceLoader>();
612        assert_send::<super::PendingFetches>();
613    }
614
615    fn js_fetch(method: &str) -> FetchKind {
616        FetchKind::JavaScript {
617            request_id: 1,
618            method: method.to_string(),
619            headers: Vec::new(),
620            body: Vec::new(),
621        }
622    }
623
624    fn header<'a>(request: &'a NetworkRequest, name: &str) -> Option<&'a str> {
625        request
626            .headers
627            .iter()
628            .find(|(n, _)| n.eq_ignore_ascii_case(name))
629            .map(|(_, v)| v.as_str())
630    }
631
632    #[test]
633    fn web_fetch_gets_browser_origin_and_origin_only_referer() {
634        let initiator = Origin::from_url_string("https://example.test/index.html");
635        let mut request = NetworkRequest::get("https://api.example.test/data".to_string());
636        request
637            .headers
638            .push(("Origin".to_string(), "https://evil.test".to_string()));
639        request
640            .headers
641            .push(("Referer".to_string(), "https://evil.test/leak".to_string()));
642
643        apply_fetch_headers(&mut request, &initiator, &js_fetch("GET"));
644
645        assert_eq!(header(&request, "Origin"), Some("https://example.test"));
646        assert_eq!(header(&request, "Referer"), Some("https://example.test"));
647    }
648
649    #[test]
650    fn non_get_requests_carry_origin_without_requiring_fetch_kind() {
651        let initiator = Origin::from_url_string("https://example.test/");
652        let mut request = NetworkRequest {
653            url: "https://api.example.test/items".to_string(),
654            method: "POST".to_string(),
655            headers: Vec::new(),
656            body: b"{}".to_vec(),
657        };
658
659        apply_fetch_headers(
660            &mut request,
661            &initiator,
662            &FetchKind::Image {
663                source: "irrelevant".to_string(),
664            },
665        );
666
667        assert_eq!(header(&request, "Origin"), Some("https://example.test"));
668        assert_eq!(header(&request, "Referer"), Some("https://example.test"));
669    }
670
671    #[test]
672    fn plain_subresources_skip_origin_header() {
673        let initiator = Origin::from_url_string("https://example.test/");
674        let mut request = NetworkRequest::get("https://cdn.example.test/pic.png".to_string());
675
676        apply_fetch_headers(
677            &mut request,
678            &initiator,
679            &FetchKind::Image {
680                source: "pic".to_string(),
681            },
682        );
683
684        assert_eq!(header(&request, "Origin"), None);
685        assert_eq!(header(&request, "Referer"), Some("https://example.test"));
686    }
687
688    #[test]
689    fn opaque_pages_never_leak_referer_or_internal_scheme() {
690        let initiator = Origin::opaque();
691        let mut request = NetworkRequest::get("resource:///devtools/index.html".to_string());
692        request
693            .headers
694            .push(("Referer".to_string(), "resource:///secret".to_string()));
695
696        apply_fetch_headers(&mut request, &initiator, &js_fetch("GET"));
697
698        assert!(
699            request.headers.is_empty(),
700            "headers = {:?}",
701            request.headers
702        );
703    }
704
705    #[test]
706    fn opaque_fetch_sends_null_origin_but_no_referer() {
707        let initiator = Origin::opaque();
708        let mut request = NetworkRequest::get("https://api.example.test/data".to_string());
709
710        apply_fetch_headers(&mut request, &initiator, &js_fetch("GET"));
711
712        assert_eq!(header(&request, "Origin"), Some("null"));
713        assert_eq!(header(&request, "Referer"), None);
714    }
715
716    #[test]
717    fn only_a_matching_generation_is_fresh() {
718        assert!(fetch_is_stale(1, 0));
719        assert!(fetch_is_stale(2, 1));
720        assert!(fetch_is_stale(0, 1));
721        assert!(!fetch_is_stale(0, 0));
722        assert!(!fetch_is_stale(7, 7));
723    }
724
725    fn pending_request(generation: u64, permissions: Permissions) -> (usize, PendingFetches) {
726        let mut pending = PendingFetches::new();
727        let id = pending.insert(
728            WindowId::from(0u64),
729            TabId(1),
730            FetchKind::Image {
731                source: "pic".to_string(),
732            },
733            Url::parse("https://example.test/pic.png").unwrap(),
734            generation,
735            Origin::from_url_string("https://example.test/"),
736            permissions,
737        );
738        (id, pending)
739    }
740
741    #[test]
742    fn pending_fetch_round_trips_origin_permissions_and_generation() {
743        let origin = Origin::from_url_string("https://example.test/");
744        let permissions = Permissions::content_for_str("https://example.test/");
745        let (id, mut pending) = pending_request(3, permissions);
746
747        let fetch = pending.remove(id).expect("just inserted");
748        assert_eq!(fetch.tab_id, TabId(1));
749        assert_eq!(fetch.generation, 3);
750        assert_eq!(fetch.origin, origin);
751        assert_eq!(fetch.permissions, permissions);
752
753        assert!(
754            pending.remove(id).is_none(),
755            "a fetch id must be consumed exactly once"
756        );
757    }
758
759    #[test]
760    fn pending_fetch_snapshots_privilege_at_request_time() {
761        // Privilege is captured with the request; it is not re-derived from the
762        // tab later, so a navigation cannot retroactively widen an old fetch.
763        let (id, mut pending) = pending_request(0, Permissions::chrome());
764        let fetch = pending.remove(id).expect("just inserted");
765        assert!(fetch.permissions.is_chrome());
766    }
767}