Skip to main content

orinium_browser/browser/core/
tab.rs

1//! ブラウザのタブ機能。WebView を保持し、ページのタイトルや URL などのメタ情報を管理する。
2
3use std::sync::Arc;
4
5use crate::{
6    browser::core::{
7        resource_loader::{BrowserNetworkError, BrowserResponse},
8        webview::JsPolicy,
9    },
10    engine::{
11        html::HtmlNodeType,
12        input::HitItem,
13        js::JsFetchResponse,
14        layouter::{
15            self,
16            types::{ColorScheme, ContainerRole, CursorStyle, InfoNode, NodeKind},
17        },
18        origin::Origin,
19        permissions::Permissions,
20        renderer_model::{self, DrawCommand},
21        tree::TreeNode,
22        ui::{CustomNode, PointerEvent, input_text_types::InputTextEvent},
23    },
24};
25use ui_layout::LayoutNode;
26use url::Url;
27use winit::event::ElementState;
28
29pub use super::webview::{CssApplicationStrategy, FetchKind, WebView, WebViewTask};
30
31pub enum TabTask {
32    Fetch {
33        url: Url,
34        kind: FetchKind,
35        /// The origin of the document that requested this resource.
36        origin: Origin,
37        /// The effective capabilities of the requesting frame.
38        permissions: Permissions,
39        /// Monotonic generation of the document that issued the fetch.
40        generation: u64,
41    },
42    NeedsRedraw,
43    /// A page asked the DevTools bridge to inspect rendered state.
44    DevToolsRequest {
45        id: u64,
46        method: String,
47        params: String,
48    },
49}
50
51#[derive(Debug)]
52enum TabError {
53    NetworkError(BrowserNetworkError),
54}
55
56#[derive(Debug)]
57enum TabState {
58    Loading,
59    Loaded,
60    Error(TabError, Option<Url>), // エラーの種類と、失敗した URL(ある場合)
61}
62
63/// Tab はブラウザで開かれた 1 つのページを表す構造体です。
64///
65/// 主な責務:
66/// - 現在表示しているページのタイトルの保持
67/// - ページ内容を扱う WebView の保持
68///
69/// WebView が「ページそのもの」の状態を管理するのに対し、
70/// Tab は UI 上のタブとしてのメタ情報(タイトルなど)を管理します。
71pub struct Tab {
72    title: Option<String>,
73    base_url: Option<Url>,
74    document_url: Option<Url>,
75    webview: Option<WebView>,
76
77    system_color_scheme: ColorScheme,
78
79    /// Policy controlling whether page scripts are executed and how
80    /// `<noscript>` contents are parsed.
81    js_policy: JsPolicy,
82
83    /// Whether this tab is a browser-created chrome context (the shell or the
84    /// DevTools pane). Only chrome contexts hold [`Permissions::chrome`].
85    privileged: bool,
86
87    /// Text measurer shared by every [`WebView`] this tab has created.
88    ///
89    /// Reusing one measurer keeps its shaping cache warm across navigations, so
90    /// text shared between documents (chrome, common markup, repeated phrases)
91    /// is not re-shaped from scratch on each page load.
92    text_measurer: Option<Arc<crate::platform::renderer::text_measurer::PlatformTextMeasurer>>,
93
94    state: TabState,
95    /// Monotonically increasing generation for the current document.
96    document_generation: u64,
97    /// Previously visited URLs, most recent last. Used by the back button.
98    history: Vec<Url>,
99
100    /// The custom node currently under the pointer, if any.
101    hovered: Option<Arc<dyn CustomNode>>,
102    /// DOM path (root-to-target) currently under the pointer.
103    hovered_dom_path: Vec<u32>,
104    /// DOM path (root-to-target) for the active/pressed target.
105    active_dom_path: Vec<u32>,
106    /// The DOM node under the pointer when the left button was pressed.
107    ///
108    /// Used to detect a completed click (press and release on the same node),
109    /// which is forwarded to the page's JS `onclick` handler.
110    pressed_dom_id: Option<u32>,
111}
112
113impl std::fmt::Debug for Tab {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("Tab")
116            .field("title", &self.title)
117            .field("base_url", &self.base_url)
118            .field("document_url", &self.document_url)
119            .field("system_color_scheme", &self.system_color_scheme)
120            .field("js_policy", &self.js_policy)
121            .field("state", &self.state)
122            .field("history", &self.history)
123            .finish()
124    }
125}
126
127impl Default for Tab {
128    fn default() -> Self {
129        Self::new(ColorScheme::default(), JsPolicy::default())
130    }
131}
132
133impl Tab {
134    pub fn new(system_color_scheme: ColorScheme, js_policy: JsPolicy) -> Self {
135        Self {
136            title: None,
137            base_url: None,
138            document_url: None,
139            webview: None,
140
141            system_color_scheme,
142
143            js_policy,
144
145            privileged: false,
146
147            text_measurer: None,
148
149            state: TabState::Loading,
150            document_generation: 0,
151            history: Vec::new(),
152
153            hovered: None,
154            hovered_dom_path: Vec::new(),
155            active_dom_path: Vec::new(),
156            pressed_dom_id: None,
157        }
158    }
159
160    /// Marks this tab as a browser-created chrome context, granting it
161    /// [`Permissions::chrome`]. Only the shell and the DevTools pane should.
162    pub fn set_privileged(&mut self, privileged: bool) {
163        self.privileged = privileged;
164    }
165
166    /// The tab's root origin.
167    ///
168    /// Chrome contexts are internal; ordinary tabs derive their origin from the
169    /// loaded document, and a tab with no document yet is opaque.
170    fn root_origin(&self) -> Origin {
171        if self.privileged {
172            return Origin::internal();
173        }
174        self.document_url
175            .as_ref()
176            .map(Origin::from_url)
177            .unwrap_or_else(Origin::opaque)
178    }
179
180    /// The effective capabilities of the tab's top-level document.
181    fn root_permissions(&self) -> Permissions {
182        if self.privileged {
183            return Permissions::chrome();
184        }
185        self.document_url
186            .as_ref()
187            .map(Permissions::content_for)
188            .unwrap_or_else(Permissions::none)
189    }
190
191    pub(crate) fn document_generation(&self) -> u64 {
192        self.document_generation
193    }
194
195    /// Whether a `fetch()`/`XMLHttpRequest` response may be read by the given
196    /// frame.
197    ///
198    /// Read authority is decided by exactly two things: whether the frame is
199    /// privileged (chrome, which bypasses CORS entirely) and, otherwise, CORS
200    /// (same-origin or `Access-Control-Allow-Origin`). The scheme capabilities
201    /// in `permissions` are deliberately ignored here: reaching a URL and being
202    /// allowed to read its response are separate questions, so a response header
203    /// can never substitute for a missing scheme capability, nor a scheme
204    /// capability for CORS.
205    fn may_read_fetch_response(
206        &self,
207        initiator: &Origin,
208        permissions: Permissions,
209        url: &Url,
210        headers: &[(String, String)],
211    ) -> bool {
212        if permissions.is_chrome() {
213            return true;
214        }
215        match url.scheme() {
216            "http" | "https" => {
217                let response_origin = Origin::from_url_string(url.as_str());
218                initiator.same_origin(&response_origin) || headers_allow_cors(headers, initiator)
219            }
220            "data" => true,
221            _ => false,
222        }
223    }
224
225    /// Tab 内の状態を 1 ステップ進める
226    ///
227    /// - WebView.tick() を呼び出す
228    /// - 発生した Task を BrowserApp に返す
229    pub(crate) fn set_animation_visible(&mut self, visible: bool) {
230        if let Some(wv) = self.webview.as_mut() {
231            wv.set_animation_visible(visible);
232        }
233    }
234
235    pub fn tick(&mut self) -> Vec<TabTask> {
236        let mut tasks = Vec::new();
237        let page_origin = self.root_origin();
238        let page_permissions = self.root_permissions();
239        let Some(wv) = self.webview.as_mut() else {
240            return tasks;
241        };
242
243        // Handle navigations after the loop: they replace the webview, which
244        // would invalidate the `wv` borrow mid-iteration.
245        let mut navigation: Option<Url> = None;
246        for task in wv.tick() {
247            match task {
248                WebViewTask::Fetch { url, kind } => {
249                    log::info!("Fetch requested in Tab: url={}", url);
250                    tasks.push(TabTask::Fetch {
251                        url,
252                        kind,
253                        origin: page_origin.clone(),
254                        permissions: page_permissions,
255                        generation: self.document_generation,
256                    });
257                }
258                WebViewTask::Navigate { url } => {
259                    // Script-initiated navigation (form submit): replace the
260                    // document URL and restart the load cycle.
261                    log::info!("Navigating (script): {url}");
262                    navigation = Some(url);
263                }
264                WebViewTask::AskTabHtml => {
265                    tasks.push(TabTask::Fetch {
266                        url: self.document_url.as_ref().unwrap().clone(),
267                        kind: FetchKind::Html,
268                        origin: page_origin.clone(),
269                        permissions: page_permissions,
270                        generation: self.document_generation,
271                    });
272                }
273                WebViewTask::DevToolsRequest { id, method, params } => {
274                    tasks.push(TabTask::DevToolsRequest { id, method, params });
275                }
276            }
277        }
278
279        if wv.needs_redraw() {
280            tasks.push(TabTask::NeedsRedraw);
281        }
282
283        // Apply the navigation last: it drops `wv`, so this must be after all
284        // other uses of the borrow above.
285        if let Some(url) = navigation {
286            self.navigate(url);
287            return tasks;
288        }
289
290        tasks
291    }
292
293    /// Delivers the result of a resource fetch to this tab.
294    ///
295    /// This is shared by regular browser tabs and the DevTools tab embedded in
296    /// the browser chrome so both follow the same loading and error handling
297    /// path.
298    ///
299    /// `generation` identifies the document that issued the fetch. Results from
300    /// a previous navigation (generation mismatch) are dropped so stale
301    /// resources cannot mutate the current document state.
302    pub(crate) fn deliver_fetch(
303        &mut self,
304        kind: FetchKind,
305        url: Url,
306        origin: Origin,
307        permissions: Permissions,
308        response: Result<BrowserResponse, BrowserNetworkError>,
309        generation: u64,
310    ) {
311        if generation != self.document_generation {
312            log::info!(
313                "Dropping stale fetch delivery for generation={} (current={}) url={}",
314                generation,
315                self.document_generation,
316                url
317            );
318            return;
319        }
320        match response {
321            Ok(resp) => match kind {
322                FetchKind::Html => {
323                    let html = String::from_utf8_lossy(&resp.body).to_string();
324                    self.on_fetch_succeeded_html(html);
325                }
326                FetchKind::Css => {
327                    let css = String::from_utf8_lossy(&resp.body).to_string();
328                    self.on_fetch_succeeded_css_from(css, &url);
329                }
330                FetchKind::Script { index } => {
331                    let source = String::from_utf8_lossy(&resp.body).to_string();
332                    self.on_fetch_succeeded_script(index, source);
333                }
334                FetchKind::DynamicScript { node_id } => {
335                    let source = String::from_utf8_lossy(&resp.body).to_string();
336                    self.on_fetch_succeeded_dynamic_script(node_id, source, &url);
337                }
338                FetchKind::DynamicCss { node_id } => {
339                    let source = String::from_utf8_lossy(&resp.body).to_string();
340                    self.on_fetch_succeeded_dynamic_style(node_id, source, &url);
341                }
342                FetchKind::CssImport { target } => {
343                    let source = String::from_utf8_lossy(&resp.body).to_string();
344                    self.on_fetch_succeeded_css_import(source, &target);
345                }
346                FetchKind::Image { source } => {
347                    self.on_fetch_succeeded_image(source, &resp.body);
348                }
349                FetchKind::Audio { source } => {
350                    self.on_fetch_succeeded_audio(source, &resp.body);
351                }
352                FetchKind::Iframe { dom_id } => {
353                    let html = String::from_utf8_lossy(&resp.body).to_string();
354                    match Url::parse(&resp.url) {
355                        Ok(final_url) => self.on_fetch_succeeded_iframe(dom_id, html, final_url),
356                        Err(error) => {
357                            log::warn!(
358                                "Iframe response has invalid final URL {}: {error}",
359                                resp.url
360                            );
361                            self.on_fetch_failed_iframe(dom_id);
362                        }
363                    }
364                }
365                FetchKind::JavaScript { request_id, .. } => {
366                    let response_url = Url::parse(&resp.url);
367                    if response_url.as_ref().is_ok_and(|response_url| {
368                        self.may_read_fetch_response(
369                            &origin,
370                            permissions,
371                            response_url,
372                            &resp.headers,
373                        )
374                    }) {
375                        let redirected = resp.url != url.as_str();
376                        self.on_fetch_succeeded_js(request_id, resp, redirected);
377                    } else {
378                        log::warn!(
379                            "Blocked CORS read of {} from {}",
380                            resp.url,
381                            origin.ascii_serialization()
382                        );
383                        self.on_fetch_failed_js(
384                            request_id,
385                            "Cross-origin response blocked by CORS policy".to_string(),
386                        );
387                    }
388                }
389            },
390            Err(err) => match kind {
391                FetchKind::Image { .. } | FetchKind::Audio { .. } => {
392                    log::warn!("Media fetch failed without aborting page load: {url}");
393                }
394                FetchKind::Iframe { dom_id } => {
395                    log::warn!("Iframe fetch failed without aborting page load: {url}");
396                    self.on_fetch_failed_iframe(dom_id);
397                }
398                FetchKind::Script { index } => {
399                    log::warn!("Classic script fetch failed without aborting page load: {url}");
400                    self.on_fetch_failed_script(index);
401                }
402                FetchKind::DynamicScript { node_id } => {
403                    log::warn!("Dynamic script fetch failed without aborting page load: {url}");
404                    self.on_fetch_failed_dynamic_script(node_id);
405                }
406                FetchKind::DynamicCss { node_id } => {
407                    log::warn!("Dynamic stylesheet fetch failed without aborting page load: {url}");
408                    self.on_fetch_failed_dynamic_style(node_id);
409                }
410                FetchKind::CssImport { target } => {
411                    log::warn!("CSS import fetch failed without aborting page load: {url}");
412                    self.on_fetch_failed_css_import(&target);
413                }
414                FetchKind::JavaScript { request_id, .. } => {
415                    self.on_fetch_failed_js(request_id, err.to_string());
416                }
417                FetchKind::Html | FetchKind::Css => self.on_fetch_failed(err, url),
418            },
419        }
420    }
421
422    /// BrowserApp から CSS fetch 完了を通知
423    pub fn on_css_fetched(&mut self, css: String) {
424        log::info!("CSS fetched in Tab");
425        if let Some(webview) = self.webview.as_mut() {
426            webview.on_css_fetched(css);
427        }
428    }
429
430    /// BrowserApp からの HTML fetch 完了を通知
431    pub fn on_fetch_succeeded_html(&mut self, html: String) {
432        let Some(wv) = self.webview.as_mut() else {
433            return;
434        };
435
436        wv.on_html_fetched(html, self.document_url.as_ref().unwrap().clone());
437        self.title = wv.title().cloned();
438        let base_url = wv.base_url().unwrap().clone();
439        log::info!("HTML fetched, base_url={}", base_url);
440        self.base_url = Some(base_url);
441
442        if let TabState::Error(TabError::NetworkError(err), url_opt) = &self.state {
443            let error_message = match url_opt {
444                Some(url) => format!("Failed to load {}: {}", url, err),
445                None => format!("Failed to load page: {}", err),
446            };
447
448            let error_message_element = wv
449                .document_info()
450                .unwrap()
451                .dom
452                .get_elements_by_class_name("error-message");
453            let error_message_element = error_message_element.first().unwrap();
454            let new_child = TreeNode::new(HtmlNodeType::Text(error_message));
455            TreeNode::replace_child(error_message_element, 0, new_child);
456
457            // Update page to show error message
458            // This is a stub implementation for now as you can see in WebView.update_page().
459            wv.update_page();
460        } else {
461            self.state = TabState::Loaded;
462        }
463    }
464
465    pub fn on_fetch_succeeded_css(&mut self, css: String) {
466        let Some(wv) = self.webview.as_mut() else {
467            return;
468        };
469
470        wv.on_css_fetched(css);
471    }
472
473    pub fn on_fetch_succeeded_css_from(&mut self, css: String, stylesheet_url: &Url) {
474        let Some(wv) = self.webview.as_mut() else {
475            return;
476        };
477        wv.on_css_fetched_from(css, stylesheet_url);
478    }
479
480    /// Delivers encoded image bytes to the page that requested them.
481    pub fn on_fetch_succeeded_image(&mut self, source: String, bytes: &[u8]) {
482        let Some(wv) = self.webview.as_mut() else {
483            return;
484        };
485        if let Err(error) = wv.on_image_fetched(source, bytes) {
486            log::warn!("Failed to decode fetched image: {error}");
487        }
488    }
489
490    /// Delivers encoded audio bytes to the page that requested them.
491    pub fn on_fetch_succeeded_audio(&mut self, source: String, bytes: &[u8]) {
492        if let Some(webview) = self.webview.as_mut() {
493            webview.on_audio_fetched(source, bytes);
494        }
495    }
496
497    /// Delivers a fetched external classic script in document order.
498    pub fn on_fetch_succeeded_script(&mut self, index: usize, source: String) {
499        if let Some(webview) = self.webview.as_mut() {
500            webview.on_script_fetched(index, source);
501        }
502    }
503
504    /// Skips a failed external classic script without replacing the page.
505    pub fn on_fetch_failed_script(&mut self, index: usize) {
506        if let Some(webview) = self.webview.as_mut() {
507            webview.on_script_fetch_failed(index);
508        }
509    }
510
511    pub fn on_fetch_succeeded_dynamic_script(&mut self, node_id: u64, source: String, url: &Url) {
512        if let Some(webview) = self.webview.as_mut() {
513            webview.on_dynamic_script_fetched(node_id, source, url);
514        }
515    }
516
517    pub fn on_fetch_failed_dynamic_script(&mut self, node_id: u64) {
518        if let Some(webview) = self.webview.as_mut() {
519            webview.on_dynamic_script_fetch_failed(node_id);
520        }
521    }
522
523    pub fn on_fetch_succeeded_dynamic_style(&mut self, node_id: u64, source: String, url: &Url) {
524        if let Some(webview) = self.webview.as_mut() {
525            webview.on_dynamic_style_fetched(node_id, source, url);
526        }
527    }
528
529    pub fn on_fetch_failed_dynamic_style(&mut self, node_id: u64) {
530        if let Some(webview) = self.webview.as_mut() {
531            webview.on_dynamic_style_fetch_failed(node_id);
532        }
533    }
534
535    /// Records a fetched `@import`-ed stylesheet against its own URL.
536    pub fn on_fetch_succeeded_css_import(&mut self, source: String, url: &Url) {
537        if let Some(webview) = self.webview.as_mut() {
538            webview.on_css_import_fetched(source, url);
539        }
540    }
541
542    /// Records a failed `@import` fetch so importers settle without the sheet.
543    pub fn on_fetch_failed_css_import(&mut self, url: &Url) {
544        if let Some(webview) = self.webview.as_mut() {
545            webview.on_css_import_fetch_failed(url);
546        }
547    }
548
549    /// Installs fetched HTML as an `<iframe>` element's `contentDocument`.
550    pub fn on_fetch_succeeded_iframe(&mut self, dom_id: u64, html: String, final_url: Url) {
551        if let Some(webview) = self.webview.as_mut() {
552            webview.on_iframe_fetched(dom_id, html, final_url);
553        }
554    }
555
556    /// Marks an iframe load as failed so later `contentDocument` accesses do
557    /// not keep re-queuing a fetch.
558    pub fn on_fetch_failed_iframe(&mut self, dom_id: u64) {
559        if let Some(webview) = self.webview.as_mut() {
560            webview.on_iframe_fetch_failed(dom_id);
561        }
562    }
563
564    /// Delivers a completed JavaScript `fetch()` response.
565    pub fn on_fetch_succeeded_js(
566        &mut self,
567        request_id: u64,
568        response: BrowserResponse,
569        redirected: bool,
570    ) {
571        if let Some(webview) = self.webview.as_mut() {
572            webview.on_js_fetch_succeeded(
573                request_id,
574                JsFetchResponse {
575                    url: response.url,
576                    status: response.status.as_u16(),
577                    status_text: response.status_text,
578                    redirected,
579                    body: response.body,
580                    headers: response.headers,
581                },
582            );
583        }
584    }
585
586    /// Delivers a JavaScript `fetch()` network failure without navigating away.
587    pub fn on_fetch_failed_js(&mut self, request_id: u64, reason: String) {
588        if let Some(webview) = self.webview.as_mut() {
589            webview.on_js_fetch_failed(request_id, reason);
590        }
591    }
592
593    /// Answers a DevTools inspection query against this tab's page.
594    pub fn inspect(&mut self, method: &str, params: &str) -> Result<serde_json::Value, String> {
595        match self.webview.as_mut() {
596            Some(webview) => webview.inspect(method, params),
597            None => Err("no page".to_string()),
598        }
599    }
600
601    pub fn draw(&mut self, cmd_buf: &mut Vec<DrawCommand>, width: f32, height: f32) {
602        self.relayout((width, height));
603
604        if let Some((layout, info)) = self.layout_and_info() {
605            renderer_model::generate_draw_commands(cmd_buf, layout, info, (width, height));
606            self.clear_redraw_flag();
607        } else {
608            log::debug!(target: "Tab", "No layout/info available for tab");
609        }
610    }
611
612    pub fn handle_mouse_input(&mut self, px: f32, py: f32, state: ElementState) -> (bool, bool) {
613        // Hit-test the content area, dispatch the pointer event to custom
614        // nodes, and remember which DOM element the press/release landed on.
615        let Some((_, info)) = self.layout_and_info() else {
616            return (false, false);
617        };
618
619        let path = self.hit_test(px, py);
620        let dom_id = crate::engine::input::hit_dom_id(&path);
621
622        let mut repaint = false;
623
624        match state {
625            ElementState::Pressed => {
626                let dismiss_path = path.clone();
627                let pointer_consumed = crate::engine::input::dispatch_pointer(
628                    &path,
629                    PointerEvent::Down { x: px, y: py },
630                );
631                // Only dismiss popups when the press was not consumed by an
632                // open popup. Presses inside a popup (or on its owner) are
633                // handled by dispatch_pointer via on_popup_pointer_event.
634                if !pointer_consumed {
635                    repaint |= crate::engine::input::dismiss_open_popups(info, &dismiss_path, px, py);
636                }
637
638                let input_target = path.iter().find_map(|hit| {
639                    if let layouter::types::NodeKind::Custom { node, .. } = &hit.info.kind
640                        && node.accepts_text_input()
641                    {
642                        Some(Arc::clone(node))
643                    } else {
644                        None
645                    }
646                });
647
648                let input_focused =
649                    crate::engine::input::focus_text_input(info, input_target.as_ref());
650
651                let href = path.iter().find_map(|hit| {
652                    if let layouter::types::NodeKind::Container { role, .. } = &hit.info.kind
653                        && let layouter::types::ContainerRole::Link { href } = role
654                    {
655                        Some(href.clone())
656                    } else {
657                        None
658                    }
659                });
660                let active_dom_path = crate::engine::input::hit_dom_path(&path);
661
662                repaint |= input_focused | pointer_consumed;
663
664                if let Some(href) = href {
665                    self.move_to(&href);
666                }
667
668                self.pressed_dom_id = dom_id;
669                self.active_dom_path = active_dom_path;
670                if let Some(wv) = self.webview.as_mut() {
671                    repaint |= wv.set_interaction_paths(
672                        self.hovered_dom_path.clone(),
673                        self.active_dom_path.clone(),
674                    );
675                }
676            }
677
678            ElementState::Released => {
679                repaint |= crate::engine::input::dispatch_pointer(
680                    &path,
681                    PointerEvent::Up { x: px, y: py },
682                );
683
684                let pressed = self.pressed_dom_id.take();
685                if let (Some(pressed), Some(released)) = (pressed, dom_id)
686                    && pressed == released
687                {
688                    repaint |= self.on_js_click(released);
689                }
690                if !self.active_dom_path.is_empty() {
691                    self.active_dom_path.clear();
692                    if let Some(wv) = self.webview.as_mut() {
693                        repaint |= wv.clear_active_interaction();
694                    }
695                }
696            }
697        }
698
699        let (move_repaint, move_focused) = self.handle_pointer_move(px, py);
700        repaint |= move_repaint;
701
702        (repaint, move_focused)
703    }
704
705    /// Dispatches a pointer move and updates hover state without touching
706    /// click bookkeeping. Unlike [`Tab::handle_mouse_input`] it never
707    /// synthesizes press/release events, so it is safe to call on every
708    /// cursor movement.
709    pub fn handle_pointer_move(&mut self, px: f32, py: f32) -> (bool, bool) {
710        let move_path = self.hit_test(px, py);
711
712        let input_focused =
713            crate::engine::input::dispatch_pointer(&move_path, PointerEvent::Move { x: px, y: py });
714
715        let previous = self.hovered.as_ref();
716        let hover_changed = crate::engine::input::update_hover(&move_path, previous);
717        let next_hovered = crate::engine::input::hit_custom_node(&move_path).cloned();
718        let hovered_dom_path = crate::engine::input::hit_dom_path(&move_path);
719
720        let mut repaint = false;
721        if hover_changed {
722            repaint = true;
723        }
724        self.hovered = next_hovered;
725
726        if hovered_dom_path != self.hovered_dom_path {
727            self.hovered_dom_path = hovered_dom_path;
728            if let Some(wv) = self.webview.as_mut() {
729                repaint |= wv.set_interaction_paths(
730                    self.hovered_dom_path.clone(),
731                    self.active_dom_path.clone(),
732                );
733            }
734        }
735
736        (repaint, input_focused)
737    }
738
739    /// Clears pointer interaction state when the pointer leaves the content.
740    pub fn handle_pointer_leave(&mut self) -> bool {
741        let mut repaint = false;
742        if let Some(prev) = self.hovered.take() {
743            prev.set_hovered(false);
744            repaint = true;
745        }
746        if !self.hovered_dom_path.is_empty() || !self.active_dom_path.is_empty() {
747            self.hovered_dom_path.clear();
748            self.active_dom_path.clear();
749            if let Some(wv) = self.webview.as_mut() {
750                repaint |= wv.set_interaction_paths(Vec::new(), Vec::new());
751            }
752        }
753        self.pressed_dom_id = None;
754        repaint
755    }
756
757    /// Whether a press is waiting for its release to complete a click.
758    #[cfg(test)]
759    pub(crate) fn has_pending_press(&self) -> bool {
760        self.pressed_dom_id.is_some()
761    }
762
763    /// Sends a text-input event (key, insert, composition) to the focused
764    /// text input, if one exists.
765    pub fn dispatch_text_input(&self, event: InputTextEvent) -> bool {
766        let Some((_, info)) = self.layout_and_info() else {
767            return false;
768        };
769        crate::engine::input::dispatch_text_input(info, event)
770    }
771
772    /// Defocuses any focused text input.
773    pub fn defocus_text_input(&self) -> bool {
774        let Some((_, info)) = self.layout_and_info() else {
775            return false;
776        };
777        crate::engine::input::focus_text_input(info, None)
778    }
779
780    /// Returns the href of the link under the given page coordinates, if any.
781    pub fn link_at(&self, px: f32, py: f32) -> Option<String> {
782        let (layout, info) = self.layout_and_info()?;
783        let path = crate::engine::input::hit_test(layout, info, px, py);
784        path.iter().find_map(|hit| {
785            if let NodeKind::Container {
786                role: ContainerRole::Link { href },
787                ..
788            } = &hit.info.kind
789            {
790                Some(href.clone())
791            } else {
792                None
793            }
794        })
795    }
796
797    /// Scrolls the scrollable container under the cursor.
798    pub fn scroll_at(&mut self, px: f32, py: f32, dx: f32, dy: f32, viewport: (f32, f32)) {
799        let Some(scrolled_id) = self.layout_and_info_mut().and_then(|(layout, info)| {
800            crate::engine::input::scroll_at(layout, info, viewport, px, py, dx, dy)
801        }) else {
802            return;
803        };
804        if scrolled_id != crate::engine::input::NO_SCROLL_DOM_ID
805            && let Some(wv) = self.webview.as_mut()
806        {
807            wv.on_js_scroll(scrolled_id);
808        }
809    }
810
811    /// Whether this tab has a layout tree ready for drawing or hit-testing.
812    pub fn has_layout(&self) -> bool {
813        self.layout_and_info().is_some()
814    }
815
816    /// Whether the currently focused text input is in the middle of a
817    /// composition (e.g. IME preedit).
818    pub fn is_text_input_composing(&self) -> bool {
819        let Some((_, info)) = self.layout_and_info() else {
820            return false;
821        };
822        crate::engine::input::focused_text_input_is_composing(info)
823    }
824
825    fn hit_test<'a>(&'a self, px: f32, py: f32) -> Vec<HitItem<'a>> {
826        let Some((layout, info)) = self.layout_and_info() else {
827            return vec![];
828        };
829
830        crate::engine::input::hit_test(layout, info, px, py)
831    }
832
833    /// Settles a DevTools inspection request with its JSON envelope.
834    pub fn on_devtools_response(&mut self, id: u64, result: String) {
835        if let Some(webview) = self.webview.as_mut() {
836            webview.on_devtools_response(id, result);
837        }
838    }
839
840    /// Display error page on fetch failure
841    pub fn on_fetch_failed(&mut self, err: BrowserNetworkError, failed_url: Url) {
842        self.navigate("resource:///error.html".parse().unwrap());
843        self.state = TabState::Error(TabError::NetworkError(err), Some(failed_url));
844    }
845
846    /// Returns the cursor style for the element under the given point, if any.
847    ///
848    /// The point is in CSS pixels relative to the viewport origin.
849    pub fn cursor_at(&self, x: f32, y: f32) -> Option<CursorStyle> {
850        self.webview.as_ref().and_then(|wv| wv.cursor_at(x, y))
851    }
852
853    pub fn navigate(&mut self, url: Url) {
854        self.navigate_internal(url, true);
855    }
856
857    /// Navigates to the previous URL in the history, if any.
858    ///
859    /// Returns `false` when there is no history to go back to.
860    pub fn go_back(&mut self) -> bool {
861        let Some(previous) = self.history.pop() else {
862            return false;
863        };
864        self.navigate_internal(previous, false);
865        true
866    }
867
868    /// Reloads the current document, if one is loaded.
869    pub fn reload(&mut self) {
870        if let Some(url) = self.document_url.clone() {
871            self.navigate_internal(url, false);
872        }
873    }
874
875    /// Returns whether the back button can navigate to a previous page.
876    pub fn can_go_back(&self) -> bool {
877        !self.history.is_empty()
878    }
879
880    /// Creates this tab's shared text measurer on first use.
881    ///
882    /// Failure to initialize a system font leaves the slot empty, and the web
883    /// view falls back to creating its own measurer.
884    fn ensure_text_measurer(&mut self) {
885        if self.text_measurer.is_none() {
886            self.text_measurer =
887                crate::platform::renderer::text_measurer::PlatformTextMeasurer::new()
888                    .ok()
889                    .map(Arc::new);
890        }
891    }
892
893    fn navigate_internal(&mut self, url: Url, record_history: bool) {
894        if record_history
895            && self.document_url.as_ref() != Some(&url)
896            && let Some(previous) = self.document_url.clone()
897        {
898            self.history.push(previous);
899        }
900        self.document_generation = self.document_generation.saturating_add(1);
901        self.document_url = Some(url);
902        self.ensure_text_measurer();
903        let mut webview = WebView::new(self.system_color_scheme, self.js_policy);
904        webview.set_privileged(self.privileged);
905        webview.set_text_measurer(self.text_measurer.clone());
906        webview.navigate();
907        self.webview = Some(webview);
908        self.state = TabState::Loading;
909        self.title = None;
910        self.base_url = None;
911        self.hovered = None;
912        self.hovered_dom_path.clear();
913        self.active_dom_path.clear();
914        self.pressed_dom_id = None;
915    }
916
917    pub fn move_to(&mut self, href: &str) {
918        let Some(base_url) = self.base_url.clone() else {
919            return;
920        };
921
922        let url = super::webview::resolve_url(&base_url, href).unwrap();
923
924        // Same-document anchor navigation (`<a href="#id">`): scroll in place
925        // instead of reloading the page, and record the new URL with a history
926        // entry like a real browser does.
927        if same_document_navigation(self.document_url.as_ref(), &url) {
928            if self.document_url.as_ref() != Some(&url)
929                && let Some(previous) = self.document_url.clone()
930            {
931                self.history.push(previous);
932            }
933            self.document_url = Some(url.clone());
934            if let Some(webview) = self.webview.as_mut() {
935                webview.scroll_to_fragment(&url);
936            }
937            return;
938        }
939
940        // navigate と同じ扱い
941        self.navigate(url)
942    }
943
944    pub fn relayout(&mut self, viewport: (f32, f32)) {
945        if let Some(wv) = self.webview.as_mut() {
946            wv.relayout(viewport);
947        }
948    }
949
950    /// Returns layout_and_info
951    /// Only InfoNode will be mutable.
952    pub(crate) fn layout_and_info_mut(&mut self) -> Option<(&LayoutNode, &mut InfoNode)> {
953        self.webview
954            .as_mut()
955            .and_then(|wv| wv.layout_and_info_mut())
956    }
957
958    /// Dispatches a click on a DOM node to the page's JS `onclick` handler.
959    ///
960    /// Returns whether the click mutated the DOM and needs a redraw.
961    pub fn on_js_click(&mut self, dom_id: u32) -> bool {
962        self.webview
963            .as_mut()
964            .is_some_and(|wv| wv.on_js_click(dom_id))
965    }
966
967    pub fn set_system_color_scheme(&mut self, scheme: ColorScheme) {
968        self.system_color_scheme = scheme;
969        if let Some(wv) = self.webview.as_mut() {
970            wv.set_system_color_scheme(scheme)
971        }
972    }
973
974    /// Returns title of the document
975    pub fn title(&self) -> Option<String> {
976        self.title.clone()
977    }
978
979    /// Returns document url
980    pub fn document_url(&self) -> Option<Url> {
981        self.document_url.clone()
982    }
983
984    pub(crate) fn layout_and_info(&self) -> Option<(&LayoutNode, &InfoNode)> {
985        self.webview.as_ref().and_then(|wv| wv.layout_and_info())
986    }
987
988    pub fn needs_redraw(&self) -> bool {
989        self.webview.as_ref().is_some_and(|wv| wv.needs_redraw())
990    }
991
992    pub fn clear_redraw_flag(&mut self) {
993        if let Some(wv) = self.webview.as_mut() {
994            wv.clear_redraw_flag();
995        }
996    }
997
998    pub fn set_js_policy(&mut self, policy: JsPolicy) {
999        self.js_policy = policy;
1000        if let Some(wv) = self.webview.as_mut() {
1001            wv.set_js_policy(policy);
1002        }
1003    }
1004}
1005
1006/// Whether the response headers allow `initiator` to read the response.
1007///
1008/// Credentials are not tracked, so a wildcard `*` grants access exactly as an
1009/// explicit origin would.
1010fn headers_allow_cors(headers: &[(String, String)], initiator: &Origin) -> bool {
1011    let serialized = initiator.ascii_serialization();
1012    headers
1013        .iter()
1014        .find(|(name, _)| name.eq_ignore_ascii_case("access-control-allow-origin"))
1015        .is_some_and(|(_, value)| value == "*" || value == &serialized)
1016}
1017
1018/// Returns whether `next` differs from `current` only in its URL fragment,
1019/// i.e. navigating to `next` stays on the same already-loaded document.
1020fn same_document_navigation(current: Option<&Url>, next: &Url) -> bool {
1021    let Some(current) = current else {
1022        return false;
1023    };
1024    fn identity(url: &Url) -> (String, Option<String>, Option<u16>, String, Option<String>) {
1025        (
1026            url.scheme().to_string(),
1027            url.host_str().map(str::to_string),
1028            url.port(),
1029            url.path().to_string(),
1030            url.query().map(str::to_string),
1031        )
1032    }
1033    identity(next) == identity(current)
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use crate::test_support::parse_url as url;
1040
1041    #[test]
1042    fn navigate_records_history() {
1043        let mut tab = Tab::default();
1044        tab.navigate(url("https://example.test/a"));
1045        tab.navigate(url("https://example.test/b"));
1046
1047        assert!(tab.can_go_back());
1048        assert_eq!(
1049            tab.document_url().as_ref().map(Url::as_str),
1050            Some("https://example.test/b")
1051        );
1052    }
1053
1054    #[test]
1055    fn navigating_to_same_url_does_not_duplicate_history() {
1056        let mut tab = Tab::default();
1057        tab.navigate(url("https://example.test/a"));
1058        tab.navigate(url("https://example.test/a"));
1059        assert!(!tab.can_go_back());
1060    }
1061
1062    #[test]
1063    fn go_back_restores_previous_url() {
1064        let mut tab = Tab::default();
1065        tab.navigate(url("https://example.test/a"));
1066        tab.navigate(url("https://example.test/b"));
1067
1068        assert!(tab.go_back());
1069        assert_eq!(
1070            tab.document_url().as_ref().map(Url::as_str),
1071            Some("https://example.test/a")
1072        );
1073        assert!(!tab.can_go_back());
1074        // No history left: going back again reports failure.
1075        assert!(!tab.go_back());
1076    }
1077
1078    #[test]
1079    fn reload_keeps_url_without_recording_history() {
1080        let mut tab = Tab::default();
1081        tab.navigate(url("https://example.test/a"));
1082        tab.reload();
1083        assert_eq!(
1084            tab.document_url().as_ref().map(Url::as_str),
1085            Some("https://example.test/a")
1086        );
1087        assert!(!tab.can_go_back());
1088    }
1089
1090    #[test]
1091    fn fragment_only_url_change_is_a_same_document_navigation() {
1092        assert!(same_document_navigation(
1093            Some(&url("https://example.test/a#one")),
1094            &url("https://example.test/a#two"),
1095        ));
1096        assert!(same_document_navigation(
1097            Some(&url("https://example.test/a?q=1#one")),
1098            &url("https://example.test/a?q=1#two"),
1099        ));
1100        assert!(same_document_navigation(
1101            Some(&url("https://example.test/a")),
1102            &url("https://example.test/a"),
1103        ));
1104        assert!(!same_document_navigation(
1105            Some(&url("https://example.test/a")),
1106            &url("https://example.test/b"),
1107        ));
1108        assert!(!same_document_navigation(
1109            Some(&url("https://www.example.test/a#one")),
1110            &url("https://example.test/a#one"),
1111        ));
1112        assert!(!same_document_navigation(
1113            Some(&url("https://example.test/a?q=1")),
1114            &url("https://example.test/a?q=2#x"),
1115        ));
1116        assert!(!same_document_navigation(
1117            None,
1118            &url("https://example.test/a#x")
1119        ));
1120    }
1121
1122    #[test]
1123    fn pointer_move_between_press_and_release_keeps_click_pending() {
1124        let mut tab = Tab::default();
1125        tab.navigate(url("https://example.test/a"));
1126        tab.on_fetch_succeeded_html("<html><body><p>click me</p></body></html>".to_string());
1127
1128        // Force a relayout, wait for the background layout thread, then draw
1129        // again so the applied tree gets its boxes positioned.
1130        let mut buf = Vec::new();
1131        tab.draw(&mut buf, 800.0, 600.0);
1132        for _ in 0..500 {
1133            for _ in tab.tick() {}
1134            if tab.layout_and_info().is_some() {
1135                break;
1136            }
1137            std::thread::sleep(std::time::Duration::from_millis(2));
1138        }
1139        tab.draw(&mut buf, 800.0, 600.0);
1140
1141        // Press on the page, move before releasing: the move must not
1142        // complete or cancel the in-flight press.
1143        tab.handle_mouse_input(400.0, 12.0, ElementState::Pressed);
1144        assert!(tab.has_pending_press(), "press should land on an element");
1145
1146        tab.handle_pointer_move(401.0, 13.0);
1147        assert!(
1148            tab.has_pending_press(),
1149            "a pointer move must not cancel an in-flight press"
1150        );
1151
1152        // The release completes the click and clears the pending press.
1153        tab.handle_mouse_input(401.0, 13.0, ElementState::Released);
1154        assert!(!tab.has_pending_press());
1155    }
1156
1157    #[test]
1158    fn select_click_lands_on_custom_node() {
1159        use crate::engine::layouter::types::{InfoNode, NodeKind, TextFlowStyle, TextStyle};
1160
1161        let mut tab = Tab::default();
1162        tab.navigate(url("https://example.test/a"));
1163        tab.on_fetch_succeeded_html(
1164            "<html><body><select id=\"sel\"><option>One</option><option>Two</option></select></body></html>"
1165                .to_string(),
1166        );
1167
1168        // Force a relayout, wait for the background layout thread, then draw
1169        // again so the applied tree gets its boxes positioned.
1170        let mut buf = Vec::new();
1171        tab.draw(&mut buf, 800.0, 600.0);
1172        for _ in 0..500 {
1173            for _ in tab.tick() {}
1174            if tab.layout_and_info().is_some() {
1175                break;
1176            }
1177            std::thread::sleep(std::time::Duration::from_millis(2));
1178        }
1179        tab.draw(&mut buf, 800.0, 600.0);
1180
1181        let (cx_base, cy_base, width, height, select_node) = {
1182            let (layout, info) = tab.layout_and_info().unwrap();
1183
1184            // Find the select custom node in the layout tree and its box position.
1185            fn find_custom_box<'a>(
1186                layout: &'a ui_layout::LayoutNode,
1187                info: &'a InfoNode,
1188            ) -> Option<(f32, f32, f32, f32, &'a Arc<dyn CustomNode>)> {
1189                if let NodeKind::Custom { node, .. } = &info.kind {
1190                    if let Some(bm) = layout.layout_box.iter().next() {
1191                        let (x, y, w, h) = (
1192                            bm.content_box.x,
1193                            bm.content_box.y,
1194                            bm.border_box.width,
1195                            bm.border_box.height,
1196                        );
1197                        return Some((x, y, w, h, &node));
1198                    }
1199                }
1200                for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
1201                    if let Some(child_node) = child_layout.node() {
1202                        if let Some(result) = find_custom_box(child_node, child_info) {
1203                            return Some(result);
1204                        }
1205                    }
1206                }
1207                None
1208            }
1209
1210            let Some((cx_base, cy_base, width, height, select_node)) =
1211                find_custom_box(layout, info)
1212            else {
1213                panic!("select custom node not found in layout tree");
1214            };
1215            (cx_base, cy_base, width, height, Arc::clone(select_node))
1216        };
1217
1218        // Popup should be closed initially.
1219        assert!(
1220            select_node
1221                .popup(&TextStyle::default(), &TextFlowStyle::default())
1222                .is_none(),
1223            "popup should start closed"
1224        );
1225
1226        // Click at the center of the select box to open the popup.
1227        let cx = cx_base + width / 2.0;
1228        let cy = cy_base + height / 2.0;
1229        tab.handle_mouse_input(cx, cy, ElementState::Pressed);
1230        assert!(
1231            tab.has_pending_press(),
1232            "press should land on the select element"
1233        );
1234        tab.handle_mouse_input(cx, cy, ElementState::Released);
1235        assert!(!tab.has_pending_press());
1236
1237        // Popup should be open after clicking the select box.
1238        assert!(
1239            select_node
1240                .popup(&TextStyle::default(), &TextFlowStyle::default())
1241                .is_some(),
1242            "popup should be open after clicking select box"
1243        );
1244
1245        // Popup should be open after clicking the select box.
1246        assert!(
1247            select_node
1248                .popup(&TextStyle::default(), &TextFlowStyle::default())
1249                .is_some(),
1250            "popup should be open after clicking select box"
1251        );
1252
1253        // Clicking on the select box again should toggle the popup closed.
1254        tab.handle_mouse_input(cx, cy, ElementState::Pressed);
1255        assert!(tab.has_pending_press());
1256        tab.handle_mouse_input(cx, cy, ElementState::Released);
1257        assert!(!tab.has_pending_press());
1258        assert!(
1259            select_node
1260                .popup(&TextStyle::default(), &TextFlowStyle::default())
1261                .is_none(),
1262            "popup should close when clicking select box again (toggle)"
1263        );
1264    }
1265
1266    #[test]
1267    fn cross_origin_fetch_requires_matching_cors_header() {
1268        let tab = Tab::default();
1269        let web_origin = Origin::from_url_string("https://example.test/index.html");
1270        let permissions = Permissions::content_for_str("https://example.test/index.html");
1271        let url = Url::parse("https://other.test/data.json").unwrap();
1272
1273        assert!(!tab.may_read_fetch_response(&web_origin, permissions, &url, &[]));
1274
1275        let wildcard = vec![("Access-Control-Allow-Origin".to_string(), "*".to_string())];
1276        assert!(tab.may_read_fetch_response(&web_origin, permissions, &url, &wildcard));
1277
1278        let matching = vec![(
1279            "Access-Control-Allow-Origin".to_string(),
1280            "https://example.test".to_string(),
1281        )];
1282        assert!(tab.may_read_fetch_response(&web_origin, permissions, &url, &matching));
1283
1284        let other = vec![(
1285            "Access-Control-Allow-Origin".to_string(),
1286            "https://other.test".to_string(),
1287        )];
1288        assert!(!tab.may_read_fetch_response(&web_origin, permissions, &url, &other));
1289    }
1290
1291    #[test]
1292    fn same_origin_fetch_is_readable_without_cors_header() {
1293        let tab = Tab::default();
1294        let web_origin = Origin::from_url_string("https://example.test/");
1295        let permissions = Permissions::content_for_str("https://example.test/");
1296        let url = Url::parse("https://example.test:443/api").unwrap();
1297        assert!(tab.may_read_fetch_response(&web_origin, permissions, &url, &[]));
1298    }
1299
1300    #[test]
1301    fn internal_page_reads_any_response_without_cors() {
1302        let tab = Tab::default();
1303        let internal = Origin::internal();
1304        let url = Url::parse("https://other.test/api").unwrap();
1305        assert!(tab.may_read_fetch_response(&internal, Permissions::chrome(), &url, &[]));
1306    }
1307
1308    #[test]
1309    fn data_page_cannot_read_internal_scheme_responses() {
1310        let tab = Tab::default();
1311        let data_origin = Origin::from_url(&Url::parse("data:text/html,test").unwrap());
1312        let permissions = Permissions::content_for_str("data:text/html,test");
1313        let resource_url = Url::parse("resource:///devtools/index.html").unwrap();
1314        assert!(!tab.may_read_fetch_response(&data_origin, permissions, &resource_url, &[]));
1315        let file_url = Url::parse("file:///etc/passwd").unwrap();
1316        assert!(!tab.may_read_fetch_response(&data_origin, permissions, &file_url, &[]));
1317    }
1318
1319    #[test]
1320    fn opaque_origin_cannot_read_internal_scheme_responses() {
1321        let tab = Tab::default();
1322        let opaque = Origin::opaque();
1323        let resource_url = Url::parse("resource:///devtools/index.html").unwrap();
1324        assert!(!tab.may_read_fetch_response(&opaque, Permissions::none(), &resource_url, &[]));
1325        let file_url = Url::parse("file:///etc/passwd").unwrap();
1326        assert!(!tab.may_read_fetch_response(&opaque, Permissions::none(), &file_url, &[]));
1327    }
1328
1329    #[test]
1330    fn web_page_reads_internal_scheme_responses_without_cors() {
1331        // The resource loader refuses to serve internal schemes to web origins,
1332        // so such responses never reach this check in practice.
1333        let tab = Tab::default();
1334        let web_origin = Origin::from_url_string("https://example.test/");
1335        let permissions = Permissions::content_for_str("https://example.test/");
1336        let url = Url::parse("data:text/plain,hello").unwrap();
1337        assert!(tab.may_read_fetch_response(&web_origin, permissions, &url, &[]));
1338    }
1339
1340    #[test]
1341    fn privileged_tab_holds_chrome_permissions() {
1342        let mut tab = Tab::default();
1343        tab.set_privileged(true);
1344        tab.navigate("resource:///devtools/index.html".parse().unwrap());
1345        assert_eq!(tab.root_permissions(), Permissions::chrome());
1346        assert!(tab.root_origin().is_internal());
1347    }
1348
1349    #[test]
1350    fn ordinary_resource_page_gets_no_chrome_permissions() {
1351        let mut tab = Tab::default();
1352        tab.navigate("resource:///test/index.html".parse().unwrap());
1353        let permissions = tab.root_permissions();
1354        assert!(!permissions.is_chrome());
1355        assert!(!permissions.file);
1356        assert!(permissions.resource);
1357        assert!(!tab.root_origin().is_internal());
1358    }
1359
1360    #[test]
1361    fn navigating_bumps_document_generation() {
1362        // The generation a fetch is tagged with must change on every
1363        // navigation, so results from a previous document are detectable.
1364        let mut tab = Tab::default();
1365        let before = tab.document_generation();
1366        tab.navigate("resource:///test/a.html".parse().unwrap());
1367        assert_eq!(tab.document_generation(), before + 1);
1368        tab.navigate("resource:///test/b.html".parse().unwrap());
1369        assert_eq!(tab.document_generation(), before + 2);
1370    }
1371
1372    #[test]
1373    fn navigation_reuses_the_same_text_measurer() {
1374        // A tab shares one measurer across its web views so the shaping cache
1375        // survives navigation. Skip when no system font is available, since
1376        // then there is nothing to share.
1377        let mut tab = Tab::default();
1378        tab.navigate("resource:///test/a.html".parse().unwrap());
1379        let Some(first) = tab.text_measurer.clone() else {
1380            return;
1381        };
1382        tab.navigate("resource:///test/b.html".parse().unwrap());
1383        let Some(second) = tab.text_measurer.clone() else {
1384            panic!("the measurer must outlive a navigation");
1385        };
1386        assert!(Arc::ptr_eq(&first, &second));
1387        // The live web view must be using the shared instance, not its own.
1388        assert!(Arc::ptr_eq(
1389            &first,
1390            tab.webview.as_ref().unwrap().text_measurer().unwrap()
1391        ));
1392    }
1393
1394    #[test]
1395    fn headers_allow_cors_requires_exact_origin_value() {
1396        let initiator = Origin::from_url_string("https://example.test/");
1397        assert!(headers_allow_cors(
1398            &[("Access-Control-Allow-Origin".to_string(), "*".to_string())],
1399            &initiator
1400        ));
1401        assert!(headers_allow_cors(
1402            &[(
1403                "ACCEss-cOntRoL-aLLow-orIgIn".to_string(),
1404                "https://example.test".to_string()
1405            )],
1406            &initiator
1407        ));
1408        assert!(!headers_allow_cors(
1409            &[(
1410                "Access-Control-Allow-Origin".to_string(),
1411                "https://other.test".to_string()
1412            )],
1413            &initiator
1414        ));
1415    }
1416
1417    #[test]
1418    fn scheme_capabilities_do_not_bypass_cors() {
1419        // Every scheme capability present, but not privileged: a cross-origin
1420        // response is still unreadable without CORS.
1421        let tab = Tab::default();
1422        let initiator = Origin::from_url_string("https://a.test/");
1423        let permissions = Permissions {
1424            network: true,
1425            resource: true,
1426            file: true,
1427            privileged: false,
1428        };
1429        let url = Url::parse("https://b.test/x").unwrap();
1430
1431        assert!(!tab.may_read_fetch_response(&initiator, permissions, &url, &[]));
1432        assert!(tab.may_read_fetch_response(
1433            &initiator,
1434            permissions,
1435            &url,
1436            &[(
1437                "Access-Control-Allow-Origin".to_string(),
1438                "https://a.test".to_string()
1439            )],
1440        ));
1441    }
1442
1443    #[test]
1444    fn privilege_is_the_only_cors_bypass() {
1445        let tab = Tab::default();
1446        let initiator = Origin::from_url_string("https://a.test/");
1447        let url = Url::parse("https://b.test/x").unwrap();
1448
1449        let privileged = Permissions {
1450            network: true,
1451            resource: true,
1452            file: true,
1453            privileged: true,
1454        };
1455        assert!(tab.may_read_fetch_response(&initiator, privileged, &url, &[]));
1456
1457        let unprivileged = Permissions {
1458            privileged: false,
1459            ..privileged
1460        };
1461        assert!(!tab.may_read_fetch_response(&initiator, unprivileged, &url, &[]));
1462    }
1463
1464    #[test]
1465    fn cors_headers_do_not_grant_local_file_read() {
1466        // A response header can never establish access to a non-http scheme.
1467        let tab = Tab::default();
1468        let initiator = Origin::from_url_string("https://a.test/");
1469        let permissions = Permissions::content_for_str("https://a.test/");
1470        let url = Url::parse("file:///etc/passwd").unwrap();
1471
1472        assert!(!tab.may_read_fetch_response(
1473            &initiator,
1474            permissions,
1475            &url,
1476            &[("Access-Control-Allow-Origin".to_string(), "*".to_string())],
1477        ));
1478    }
1479}