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, InfoNode, NodeKind},
17        },
18        origin::Origin,
19        renderer_model::{self, DrawCommand},
20        tree::TreeNode,
21        ui::{CustomNode, PointerEvent, input_text_types::InputTextEvent},
22    },
23};
24use ui_layout::LayoutNode;
25use url::Url;
26use winit::event::ElementState;
27
28pub use super::webview::{CssApplicationStrategy, FetchKind, WebView, WebViewTask};
29
30pub enum TabTask {
31    Fetch {
32        url: Url,
33        kind: FetchKind,
34        /// The origin of the document that requested this resource.
35        origin: Origin,
36    },
37    NeedsRedraw,
38    /// A page asked the DevTools bridge to inspect rendered state.
39    DevToolsRequest {
40        id: u64,
41        method: String,
42        params: String,
43    },
44}
45
46#[derive(Debug)]
47enum TabError {
48    NetworkError(BrowserNetworkError),
49}
50
51#[derive(Debug)]
52enum TabState {
53    Loading,
54    Loaded,
55    Error(TabError, Option<Url>), // エラーの種類と、失敗した URL(ある場合)
56}
57
58/// Tab はブラウザで開かれた 1 つのページを表す構造体です。
59///
60/// 主な責務:
61/// - 現在表示しているページのタイトルの保持
62/// - ページ内容を扱う WebView の保持
63///
64/// WebView が「ページそのもの」の状態を管理するのに対し、
65/// Tab は UI 上のタブとしてのメタ情報(タイトルなど)を管理します。
66pub struct Tab {
67    title: Option<String>,
68    base_url: Option<Url>,
69    document_url: Option<Url>,
70    webview: Option<WebView>,
71
72    system_color_scheme: ColorScheme,
73
74    /// Policy controlling whether page scripts are executed and how
75    /// `<noscript>` contents are parsed.
76    js_policy: JsPolicy,
77
78    state: TabState,
79    /// Previously visited URLs, most recent last. Used by the back button.
80    history: Vec<Url>,
81
82    /// The custom node currently under the pointer, if any.
83    hovered: Option<Arc<dyn CustomNode>>,
84    /// The DOM node under the pointer when the left button was pressed.
85    ///
86    /// Used to detect a completed click (press and release on the same node),
87    /// which is forwarded to the page's JS `onclick` handler.
88    pressed_dom_id: Option<u32>,
89}
90
91impl std::fmt::Debug for Tab {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("Tab")
94            .field("title", &self.title)
95            .field("base_url", &self.base_url)
96            .field("document_url", &self.document_url)
97            .field("system_color_scheme", &self.system_color_scheme)
98            .field("js_policy", &self.js_policy)
99            .field("state", &self.state)
100            .field("history", &self.history)
101            .finish()
102    }
103}
104
105impl Default for Tab {
106    fn default() -> Self {
107        Self::new(ColorScheme::default(), JsPolicy::default())
108    }
109}
110
111impl Tab {
112    pub fn new(system_color_scheme: ColorScheme, js_policy: JsPolicy) -> Self {
113        Self {
114            title: None,
115            base_url: None,
116            document_url: None,
117            webview: None,
118
119            system_color_scheme,
120
121            js_policy,
122
123            state: TabState::Loading,
124            history: Vec::new(),
125
126            hovered: None,
127            pressed_dom_id: None,
128        }
129    }
130
131    /// The tab's current page origin, derived from the loaded document URL.
132    ///
133    /// Opaque until a document has been fetched, so pages backed by internal
134    /// schemes (e.g. the DevTools page) are treated as non-network origins.
135    fn page_origin(&self) -> Origin {
136        self.document_url
137            .as_ref()
138            .map(Origin::from_url)
139            .unwrap_or_else(Origin::opaque)
140    }
141
142    /// Whether a `fetch()`/`XMLHttpRequest` response may be read by the given
143    /// initiator.
144    ///
145    /// Internal (opaque) initiators may always read responses; web origins are
146    /// restricted to same-origin responses and cross-origin responses that opt
147    /// in via `Access-Control-Allow-Origin`. Responses targeted at internal
148    /// schemes are exempt here because the resource loader already prevented
149    /// web origins from ever receiving them.
150    fn may_read_fetch_response(
151        &self,
152        initiator: &Origin,
153        url: &Url,
154        headers: &[(String, String)],
155    ) -> bool {
156        if !initiator.is_network() {
157            return true;
158        }
159        match url.scheme() {
160            "http" | "https" => {
161                let response_origin = Origin::from_url_string(url.as_str());
162                initiator.same_origin(&response_origin) || headers_allow_cors(headers, initiator)
163            }
164            _ => true,
165        }
166    }
167
168    /// Tab 内の状態を 1 ステップ進める
169    ///
170    /// - WebView.tick() を呼び出す
171    /// - 発生した Task を BrowserApp に返す
172    pub fn tick(&mut self) -> Vec<TabTask> {
173        let mut tasks = Vec::new();
174        let page_origin = self.page_origin();
175        let Some(wv) = self.webview.as_mut() else {
176            return tasks;
177        };
178
179        for task in wv.tick() {
180            match task {
181                WebViewTask::Fetch { url, kind } => {
182                    log::info!("Fetch requested in Tab: url={}", url);
183                    tasks.push(TabTask::Fetch {
184                        url,
185                        kind,
186                        origin: page_origin.clone(),
187                    });
188                }
189                WebViewTask::AskTabHtml => {
190                    tasks.push(TabTask::Fetch {
191                        url: self.document_url.as_ref().unwrap().clone(),
192                        kind: FetchKind::Html,
193                        origin: page_origin.clone(),
194                    });
195                }
196                WebViewTask::DevToolsRequest { id, method, params } => {
197                    tasks.push(TabTask::DevToolsRequest { id, method, params });
198                }
199            }
200        }
201
202        if wv.needs_redraw() {
203            tasks.push(TabTask::NeedsRedraw);
204        }
205
206        tasks
207    }
208
209    /// Delivers the result of a resource fetch to this tab.
210    ///
211    /// This is shared by regular browser tabs and the DevTools tab embedded in
212    /// the browser chrome so both follow the same loading and error handling
213    /// path.
214    pub(crate) fn deliver_fetch(
215        &mut self,
216        kind: FetchKind,
217        url: Url,
218        response: Result<BrowserResponse, BrowserNetworkError>,
219    ) {
220        match response {
221            Ok(resp) => match kind {
222                FetchKind::Html => {
223                    let html = String::from_utf8_lossy(&resp.body).to_string();
224                    self.on_fetch_succeeded_html(html);
225                }
226                FetchKind::Css => {
227                    let css = String::from_utf8_lossy(&resp.body).to_string();
228                    self.on_fetch_succeeded_css_from(css, &url);
229                }
230                FetchKind::Script { index } => {
231                    let source = String::from_utf8_lossy(&resp.body).to_string();
232                    self.on_fetch_succeeded_script(index, source);
233                }
234                FetchKind::DynamicScript { node_id } => {
235                    let source = String::from_utf8_lossy(&resp.body).to_string();
236                    self.on_fetch_succeeded_dynamic_script(node_id, source);
237                }
238                FetchKind::DynamicCss { node_id } => {
239                    let source = String::from_utf8_lossy(&resp.body).to_string();
240                    self.on_fetch_succeeded_dynamic_style(node_id, source);
241                }
242                FetchKind::Image { source } => {
243                    self.on_fetch_succeeded_image(source, &resp.body);
244                }
245                FetchKind::Audio { source } => {
246                    self.on_fetch_succeeded_audio(source, &resp.body);
247                }
248                FetchKind::Iframe { dom_id } => {
249                    let html = String::from_utf8_lossy(&resp.body).to_string();
250                    self.on_fetch_succeeded_iframe(dom_id, html);
251                }
252                FetchKind::JavaScript { request_id, .. } => {
253                    let initiator = self.page_origin();
254                    if self.may_read_fetch_response(&initiator, &url, &resp.headers) {
255                        let redirected = resp.url != url.as_str();
256                        self.on_fetch_succeeded_js(request_id, resp, redirected);
257                    } else {
258                        log::warn!(
259                            "Blocked CORS read of {url} from {}",
260                            initiator.ascii_serialization()
261                        );
262                        self.on_fetch_failed_js(
263                            request_id,
264                            "Cross-origin response blocked by CORS policy".to_string(),
265                        );
266                    }
267                }
268            },
269            Err(err) => match kind {
270                FetchKind::Image { .. } | FetchKind::Audio { .. } => {
271                    log::warn!("Media fetch failed without aborting page load: {url}");
272                }
273                FetchKind::Iframe { dom_id } => {
274                    log::warn!("Iframe fetch failed without aborting page load: {url}");
275                    self.on_fetch_failed_iframe(dom_id);
276                }
277                FetchKind::Script { index } => {
278                    log::warn!("Classic script fetch failed without aborting page load: {url}");
279                    self.on_fetch_failed_script(index);
280                }
281                FetchKind::DynamicScript { node_id } => {
282                    log::warn!("Dynamic script fetch failed without aborting page load: {url}");
283                    self.on_fetch_failed_dynamic_script(node_id);
284                }
285                FetchKind::DynamicCss { node_id } => {
286                    log::warn!("Dynamic stylesheet fetch failed without aborting page load: {url}");
287                    self.on_fetch_failed_dynamic_style(node_id);
288                }
289                FetchKind::JavaScript { request_id, .. } => {
290                    self.on_fetch_failed_js(request_id, err.to_string());
291                }
292                FetchKind::Html | FetchKind::Css => self.on_fetch_failed(err, url),
293            },
294        }
295    }
296
297    /// BrowserApp から CSS fetch 完了を通知
298    pub fn on_css_fetched(&mut self, css: String) {
299        log::info!("CSS fetched in Tab");
300        if let Some(webview) = self.webview.as_mut() {
301            webview.on_css_fetched(css);
302        }
303    }
304
305    /// BrowserApp からの HTML fetch 完了を通知
306    pub fn on_fetch_succeeded_html(&mut self, html: String) {
307        let Some(wv) = self.webview.as_mut() else {
308            return;
309        };
310
311        wv.on_html_fetched(html, self.document_url.as_ref().unwrap().clone());
312        self.title = wv.title().cloned();
313        let base_url = wv.base_url().unwrap().clone();
314        log::info!("HTML fetched, base_url={}", base_url);
315        self.base_url = Some(base_url);
316
317        if let TabState::Error(TabError::NetworkError(err), url_opt) = &self.state {
318            let error_message = match url_opt {
319                Some(url) => format!("Failed to load {}: {}", url, err),
320                None => format!("Failed to load page: {}", err),
321            };
322
323            let error_message_element = wv
324                .document_info()
325                .unwrap()
326                .dom
327                .get_elements_by_class_name("error-message");
328            let error_message_element = error_message_element.first().unwrap();
329            let new_child = TreeNode::new(HtmlNodeType::Text(error_message));
330            TreeNode::replace_child(error_message_element, 0, new_child);
331
332            // Update page to show error message
333            // This is a stub implementation for now as you can see in WebView.update_page().
334            wv.update_page();
335        } else {
336            self.state = TabState::Loaded;
337        }
338    }
339
340    pub fn on_fetch_succeeded_css(&mut self, css: String) {
341        let Some(wv) = self.webview.as_mut() else {
342            return;
343        };
344
345        wv.on_css_fetched(css);
346    }
347
348    pub fn on_fetch_succeeded_css_from(&mut self, css: String, stylesheet_url: &Url) {
349        let Some(wv) = self.webview.as_mut() else {
350            return;
351        };
352        wv.on_css_fetched_from(css, stylesheet_url);
353    }
354
355    /// Delivers encoded image bytes to the page that requested them.
356    pub fn on_fetch_succeeded_image(&mut self, source: String, bytes: &[u8]) {
357        let Some(wv) = self.webview.as_mut() else {
358            return;
359        };
360        if let Err(error) = wv.on_image_fetched(source, bytes) {
361            log::warn!("Failed to decode fetched image: {error}");
362        }
363    }
364
365    /// Delivers encoded audio bytes to the page that requested them.
366    pub fn on_fetch_succeeded_audio(&mut self, source: String, bytes: &[u8]) {
367        if let Some(webview) = self.webview.as_mut() {
368            webview.on_audio_fetched(source, bytes);
369        }
370    }
371
372    /// Delivers a fetched external classic script in document order.
373    pub fn on_fetch_succeeded_script(&mut self, index: usize, source: String) {
374        if let Some(webview) = self.webview.as_mut() {
375            webview.on_script_fetched(index, source);
376        }
377    }
378
379    /// Skips a failed external classic script without replacing the page.
380    pub fn on_fetch_failed_script(&mut self, index: usize) {
381        if let Some(webview) = self.webview.as_mut() {
382            webview.on_script_fetch_failed(index);
383        }
384    }
385
386    pub fn on_fetch_succeeded_dynamic_script(&mut self, node_id: u64, source: String) {
387        if let Some(webview) = self.webview.as_mut() {
388            webview.on_dynamic_script_fetched(node_id, source);
389        }
390    }
391
392    pub fn on_fetch_failed_dynamic_script(&mut self, node_id: u64) {
393        if let Some(webview) = self.webview.as_mut() {
394            webview.on_dynamic_script_fetch_failed(node_id);
395        }
396    }
397
398    pub fn on_fetch_succeeded_dynamic_style(&mut self, node_id: u64, source: String) {
399        if let Some(webview) = self.webview.as_mut() {
400            webview.on_dynamic_style_fetched(node_id, source);
401        }
402    }
403
404    pub fn on_fetch_failed_dynamic_style(&mut self, node_id: u64) {
405        if let Some(webview) = self.webview.as_mut() {
406            webview.on_dynamic_style_fetch_failed(node_id);
407        }
408    }
409
410    /// Installs fetched HTML as an `<iframe>` element's `contentDocument`.
411    pub fn on_fetch_succeeded_iframe(&mut self, dom_id: u64, html: String) {
412        if let Some(webview) = self.webview.as_mut() {
413            webview.on_iframe_fetched(dom_id, html);
414        }
415    }
416
417    /// Marks an iframe load as failed so later `contentDocument` accesses do
418    /// not keep re-queuing a fetch.
419    pub fn on_fetch_failed_iframe(&mut self, dom_id: u64) {
420        if let Some(webview) = self.webview.as_mut() {
421            webview.on_iframe_fetch_failed(dom_id);
422        }
423    }
424
425    /// Delivers a completed JavaScript `fetch()` response.
426    pub fn on_fetch_succeeded_js(
427        &mut self,
428        request_id: u64,
429        response: BrowserResponse,
430        redirected: bool,
431    ) {
432        if let Some(webview) = self.webview.as_mut() {
433            webview.on_js_fetch_succeeded(
434                request_id,
435                JsFetchResponse {
436                    url: response.url,
437                    status: response.status.as_u16(),
438                    status_text: response.status_text,
439                    redirected,
440                    body: response.body,
441                    headers: response.headers,
442                },
443            );
444        }
445    }
446
447    /// Delivers a JavaScript `fetch()` network failure without navigating away.
448    pub fn on_fetch_failed_js(&mut self, request_id: u64, reason: String) {
449        if let Some(webview) = self.webview.as_mut() {
450            webview.on_js_fetch_failed(request_id, reason);
451        }
452    }
453
454    /// Answers a DevTools inspection query against this tab's page.
455    pub fn inspect(&mut self, method: &str, params: &str) -> Result<serde_json::Value, String> {
456        match self.webview.as_mut() {
457            Some(webview) => webview.inspect(method, params),
458            None => Err("no page".to_string()),
459        }
460    }
461
462    pub fn draw(&mut self, cmd_buf: &mut Vec<DrawCommand>, width: f32, height: f32) {
463        self.relayout((width, height));
464
465        if let Some((layout, info)) = self.layout_and_info() {
466            renderer_model::generate_draw_commands(cmd_buf, layout, info, (width, height));
467            self.clear_redraw_flag();
468        } else {
469            log::debug!(target: "Tab", "No layout/info available for tab");
470        }
471    }
472
473    pub fn handle_mouse_input(&mut self, px: f32, py: f32, state: ElementState) -> (bool, bool) {
474        // Hit-test the content area, dispatch the pointer event to custom
475        // nodes, and remember which DOM element the press/release landed on.
476        let Some((_, info)) = self.layout_and_info() else {
477            return (false, false);
478        };
479
480        let path = self.hit_test(px, py);
481        let dom_id = crate::engine::input::hit_dom_id(&path);
482
483        let mut repaint = false;
484
485        match state {
486            ElementState::Pressed => {
487                crate::engine::input::dismiss_open_popups(info, &path);
488
489                let input_target = path.iter().find_map(|hit| {
490                    if let layouter::types::NodeKind::Custom { node, .. } = &hit.info.kind
491                        && node.accepts_text_input()
492                    {
493                        Some(Arc::clone(node))
494                    } else {
495                        None
496                    }
497                });
498
499                let input_focused =
500                    crate::engine::input::focus_text_input(info, input_target.as_ref());
501
502                crate::engine::input::dispatch_pointer(&path, PointerEvent::Down { x: px, y: py });
503
504                if let Some(href) = path.iter().find_map(|hit| {
505                    if let layouter::types::NodeKind::Container { role, .. } = &hit.info.kind
506                        && let layouter::types::ContainerRole::Link { href } = role
507                    {
508                        Some(href.clone())
509                    } else {
510                        None
511                    }
512                }) {
513                    self.move_to(&href);
514                }
515
516                repaint |= input_focused;
517
518                self.pressed_dom_id = dom_id;
519            }
520
521            ElementState::Released => {
522                crate::engine::input::dispatch_pointer(&path, PointerEvent::Up { x: px, y: py });
523
524                let pressed = self.pressed_dom_id.take();
525                if let (Some(pressed), Some(released)) = (pressed, dom_id)
526                    && pressed == released
527                {
528                    repaint |= self.on_js_click(released);
529                }
530            }
531        }
532
533        let (move_repaint, move_focused) = self.handle_pointer_move(px, py);
534        repaint |= move_repaint;
535
536        (repaint, move_focused)
537    }
538
539    /// Dispatches a pointer move and updates hover state without touching
540    /// click bookkeeping. Unlike [`Tab::handle_mouse_input`] it never
541    /// synthesizes press/release events, so it is safe to call on every
542    /// cursor movement.
543    pub fn handle_pointer_move(&mut self, px: f32, py: f32) -> (bool, bool) {
544        let move_path = self.hit_test(px, py);
545
546        let input_focused =
547            crate::engine::input::dispatch_pointer(&move_path, PointerEvent::Move { x: px, y: py });
548
549        let hover_changed = {
550            let previous = self.hovered.as_ref();
551            crate::engine::input::update_hover(&move_path, previous)
552        };
553
554        let mut repaint = false;
555        if hover_changed {
556            repaint = true;
557            self.hovered = crate::engine::input::hit_custom_node(&move_path).cloned();
558        }
559
560        (repaint, input_focused)
561    }
562
563    /// Whether a press is waiting for its release to complete a click.
564    #[cfg(test)]
565    pub(crate) fn has_pending_press(&self) -> bool {
566        self.pressed_dom_id.is_some()
567    }
568
569    /// Sends a text-input event (key, insert, composition) to the focused
570    /// text input, if one exists.
571    pub fn dispatch_text_input(&self, event: InputTextEvent) -> bool {
572        let Some((_, info)) = self.layout_and_info() else {
573            return false;
574        };
575        crate::engine::input::dispatch_text_input(info, event)
576    }
577
578    /// Defocuses any focused text input.
579    pub fn defocus_text_input(&self) -> bool {
580        let Some((_, info)) = self.layout_and_info() else {
581            return false;
582        };
583        crate::engine::input::focus_text_input(info, None)
584    }
585
586    /// Returns the href of the link under the given page coordinates, if any.
587    pub fn link_at(&self, px: f32, py: f32) -> Option<String> {
588        let (layout, info) = self.layout_and_info()?;
589        let path = crate::engine::input::hit_test(layout, info, px, py);
590        path.iter().find_map(|hit| {
591            if let NodeKind::Container {
592                role: ContainerRole::Link { href },
593                ..
594            } = &hit.info.kind
595            {
596                Some(href.clone())
597            } else {
598                None
599            }
600        })
601    }
602
603    /// Scrolls the scrollable container under the cursor.
604    pub fn scroll_at(&mut self, px: f32, py: f32, dx: f32, dy: f32, viewport: (f32, f32)) {
605        let Some(scrolled_id) = self.layout_and_info_mut().and_then(|(layout, info)| {
606            crate::engine::input::scroll_at(layout, info, viewport, px, py, dx, dy)
607        }) else {
608            return;
609        };
610        if scrolled_id != crate::engine::input::NO_SCROLL_DOM_ID
611            && let Some(wv) = self.webview.as_mut()
612        {
613            wv.on_js_scroll(scrolled_id);
614        }
615    }
616
617    /// Whether this tab has a layout tree ready for drawing or hit-testing.
618    pub fn has_layout(&self) -> bool {
619        self.layout_and_info().is_some()
620    }
621
622    /// Whether the currently focused text input is in the middle of a
623    /// composition (e.g. IME preedit).
624    pub fn is_text_input_composing(&self) -> bool {
625        let Some((_, info)) = self.layout_and_info() else {
626            return false;
627        };
628        crate::engine::input::focused_text_input_is_composing(info)
629    }
630
631    fn hit_test<'a>(&'a self, px: f32, py: f32) -> Vec<HitItem<'a>> {
632        let Some((layout, info)) = self.layout_and_info() else {
633            return vec![];
634        };
635
636        crate::engine::input::hit_test(layout, info, px, py)
637    }
638
639    /// Settles a DevTools inspection request with its JSON envelope.
640    pub fn on_devtools_response(&mut self, id: u64, result: String) {
641        if let Some(webview) = self.webview.as_mut() {
642            webview.on_devtools_response(id, result);
643        }
644    }
645
646    /// Display error page on fetch failure
647    pub fn on_fetch_failed(&mut self, err: BrowserNetworkError, failed_url: Url) {
648        self.navigate("resource:///error.html".parse().unwrap());
649        self.state = TabState::Error(TabError::NetworkError(err), Some(failed_url));
650    }
651
652    pub fn navigate(&mut self, url: Url) {
653        self.navigate_internal(url, true);
654    }
655
656    /// Navigates to the previous URL in the history, if any.
657    ///
658    /// Returns `false` when there is no history to go back to.
659    pub fn go_back(&mut self) -> bool {
660        let Some(previous) = self.history.pop() else {
661            return false;
662        };
663        self.navigate_internal(previous, false);
664        true
665    }
666
667    /// Reloads the current document, if one is loaded.
668    pub fn reload(&mut self) {
669        if let Some(url) = self.document_url.clone() {
670            self.navigate_internal(url, false);
671        }
672    }
673
674    /// Returns whether the back button can navigate to a previous page.
675    pub fn can_go_back(&self) -> bool {
676        !self.history.is_empty()
677    }
678
679    fn navigate_internal(&mut self, url: Url, record_history: bool) {
680        if record_history
681            && self.document_url.as_ref() != Some(&url)
682            && let Some(previous) = self.document_url.clone()
683        {
684            self.history.push(previous);
685        }
686        self.document_url = Some(url);
687        let mut webview = WebView::new(self.system_color_scheme, self.js_policy);
688        webview.navigate();
689        self.webview = Some(webview);
690        self.state = TabState::Loading;
691        self.title = None;
692        self.base_url = None;
693    }
694
695    pub fn move_to(&mut self, href: &str) {
696        let base_url = match self.base_url.as_ref() {
697            Some(u) => u,
698            None => return,
699        };
700
701        let url = super::webview::resolve_url(base_url, href).unwrap();
702
703        // navigate と同じ扱い
704        self.navigate(url)
705    }
706
707    pub fn relayout(&mut self, viewport: (f32, f32)) {
708        if let Some(wv) = self.webview.as_mut() {
709            wv.relayout(viewport);
710        }
711    }
712
713    /// Returns layout_and_info
714    /// Only InfoNode will be mutable.
715    pub(crate) fn layout_and_info_mut(&mut self) -> Option<(&LayoutNode, &mut InfoNode)> {
716        self.webview
717            .as_mut()
718            .and_then(|wv| wv.layout_and_info_mut())
719    }
720
721    /// Dispatches a click on a DOM node to the page's JS `onclick` handler.
722    ///
723    /// Returns whether the click mutated the DOM and needs a redraw.
724    pub fn on_js_click(&mut self, dom_id: u32) -> bool {
725        self.webview
726            .as_mut()
727            .is_some_and(|wv| wv.on_js_click(dom_id))
728    }
729
730    pub fn set_system_color_scheme(&mut self, scheme: ColorScheme) {
731        self.system_color_scheme = scheme;
732        if let Some(wv) = self.webview.as_mut() {
733            wv.set_system_color_scheme(scheme)
734        }
735    }
736
737    /// Returns title of the document
738    pub fn title(&self) -> Option<String> {
739        self.title.clone()
740    }
741
742    /// Returns document url
743    pub fn document_url(&self) -> Option<Url> {
744        self.document_url.clone()
745    }
746
747    pub(crate) fn layout_and_info(&self) -> Option<(&LayoutNode, &InfoNode)> {
748        self.webview.as_ref().and_then(|wv| wv.layout_and_info())
749    }
750
751    pub fn needs_redraw(&self) -> bool {
752        self.webview.as_ref().is_some_and(|wv| wv.needs_redraw())
753    }
754
755    pub fn clear_redraw_flag(&mut self) {
756        if let Some(wv) = self.webview.as_mut() {
757            wv.clear_redraw_flag();
758        }
759    }
760
761    pub fn set_js_policy(&mut self, policy: JsPolicy) {
762        self.js_policy = policy;
763        if let Some(wv) = self.webview.as_mut() {
764            wv.set_js_policy(policy);
765        }
766    }
767}
768
769/// Whether the response headers allow `initiator` to read the response.
770///
771/// Credentials are not tracked, so a wildcard `*` grants access exactly as an
772/// explicit origin would.
773fn headers_allow_cors(headers: &[(String, String)], initiator: &Origin) -> bool {
774    let serialized = initiator.ascii_serialization();
775    headers
776        .iter()
777        .find(|(name, _)| name.eq_ignore_ascii_case("access-control-allow-origin"))
778        .is_some_and(|(_, value)| value == "*" || value == &serialized)
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    fn url(s: &str) -> Url {
786        Url::parse(s).unwrap()
787    }
788
789    #[test]
790    fn navigate_records_history() {
791        let mut tab = Tab::default();
792        tab.navigate(url("https://example.test/a"));
793        tab.navigate(url("https://example.test/b"));
794
795        assert!(tab.can_go_back());
796        assert_eq!(
797            tab.document_url().as_ref().map(Url::as_str),
798            Some("https://example.test/b")
799        );
800    }
801
802    #[test]
803    fn navigating_to_same_url_does_not_duplicate_history() {
804        let mut tab = Tab::default();
805        tab.navigate(url("https://example.test/a"));
806        tab.navigate(url("https://example.test/a"));
807        assert!(!tab.can_go_back());
808    }
809
810    #[test]
811    fn go_back_restores_previous_url() {
812        let mut tab = Tab::default();
813        tab.navigate(url("https://example.test/a"));
814        tab.navigate(url("https://example.test/b"));
815
816        assert!(tab.go_back());
817        assert_eq!(
818            tab.document_url().as_ref().map(Url::as_str),
819            Some("https://example.test/a")
820        );
821        assert!(!tab.can_go_back());
822        // No history left: going back again reports failure.
823        assert!(!tab.go_back());
824    }
825
826    #[test]
827    fn reload_keeps_url_without_recording_history() {
828        let mut tab = Tab::default();
829        tab.navigate(url("https://example.test/a"));
830        tab.reload();
831        assert_eq!(
832            tab.document_url().as_ref().map(Url::as_str),
833            Some("https://example.test/a")
834        );
835        assert!(!tab.can_go_back());
836    }
837
838    #[test]
839    fn pointer_move_between_press_and_release_keeps_click_pending() {
840        let mut tab = Tab::default();
841        tab.navigate(url("https://example.test/a"));
842        tab.on_fetch_succeeded_html("<html><body><p>click me</p></body></html>".to_string());
843
844        // Force a relayout, wait for the background layout thread, then draw
845        // again so the applied tree gets its boxes positioned.
846        let mut buf = Vec::new();
847        tab.draw(&mut buf, 800.0, 600.0);
848        for _ in 0..500 {
849            for _ in tab.tick() {}
850            if tab.layout_and_info().is_some() {
851                break;
852            }
853            std::thread::sleep(std::time::Duration::from_millis(2));
854        }
855        tab.draw(&mut buf, 800.0, 600.0);
856
857        // Press on the page, move before releasing: the move must not
858        // complete or cancel the in-flight press.
859        tab.handle_mouse_input(400.0, 12.0, ElementState::Pressed);
860        assert!(tab.has_pending_press(), "press should land on an element");
861
862        tab.handle_pointer_move(401.0, 13.0);
863        assert!(
864            tab.has_pending_press(),
865            "a pointer move must not cancel an in-flight press"
866        );
867
868        // The release completes the click and clears the pending press.
869        tab.handle_mouse_input(401.0, 13.0, ElementState::Released);
870        assert!(!tab.has_pending_press());
871    }
872
873    #[test]
874    fn cross_origin_fetch_requires_matching_cors_header() {
875        let tab = Tab::default();
876        let web_origin = Origin::from_url_string("https://example.test/index.html");
877        let url = Url::parse("https://other.test/data.json").unwrap();
878
879        assert!(!tab.may_read_fetch_response(&web_origin, &url, &[]));
880
881        let wildcard = vec![("Access-Control-Allow-Origin".to_string(), "*".to_string())];
882        assert!(tab.may_read_fetch_response(&web_origin, &url, &wildcard));
883
884        let matching = vec![(
885            "Access-Control-Allow-Origin".to_string(),
886            "https://example.test".to_string(),
887        )];
888        assert!(tab.may_read_fetch_response(&web_origin, &url, &matching));
889
890        let other = vec![(
891            "Access-Control-Allow-Origin".to_string(),
892            "https://other.test".to_string(),
893        )];
894        assert!(!tab.may_read_fetch_response(&web_origin, &url, &other));
895    }
896
897    #[test]
898    fn same_origin_fetch_is_readable_without_cors_header() {
899        let tab = Tab::default();
900        let web_origin = Origin::from_url_string("https://example.test/");
901        let url = Url::parse("https://example.test:443/api").unwrap();
902        assert!(tab.may_read_fetch_response(&web_origin, &url, &[]));
903    }
904
905    #[test]
906    fn internal_page_reads_any_response_without_cors() {
907        let tab = Tab::default();
908        let internal = Origin::opaque();
909        let url = Url::parse("https://other.test/api").unwrap();
910        assert!(tab.may_read_fetch_response(&internal, &url, &[]));
911    }
912
913    #[test]
914    fn web_page_reads_internal_scheme_responses_without_cors() {
915        // The resource loader refuses to serve internal schemes to web origins,
916        // so such responses never reach this check in practice.
917        let tab = Tab::default();
918        let web_origin = Origin::from_url_string("https://example.test/");
919        let url = Url::parse("data:text/plain,hello").unwrap();
920        assert!(tab.may_read_fetch_response(&web_origin, &url, &[]));
921    }
922
923    #[test]
924    fn headers_allow_cors_requires_exact_origin_value() {
925        let initiator = Origin::from_url_string("https://example.test/");
926        assert!(headers_allow_cors(
927            &[("Access-Control-Allow-Origin".to_string(), "*".to_string())],
928            &initiator
929        ));
930        assert!(headers_allow_cors(
931            &[(
932                "ACCEss-cOntRoL-aLLow-orIgIn".to_string(),
933                "https://example.test".to_string()
934            )],
935            &initiator
936        ));
937        assert!(!headers_allow_cors(
938            &[(
939                "Access-Control-Allow-Origin".to_string(),
940                "https://other.test".to_string()
941            )],
942            &initiator
943        ));
944    }
945}