Skip to main content

fc_dioxus/components/
webview.rs

1//! Embedded browser chrome from webview.css: page tabs, back and forward, an address
2//! bar with a security indicator, the frame, and a load-error overlay.
3//!
4//! Chrome, not a browser: it never reads or mediates the embedded page, which a
5//! cross-origin frame forbids anyway. History is the control's own, since a frame's
6//! cross-origin history is out of reach.
7use crate::{
8    behavior::{
9        roving,
10        url::{frame_url, is_secure},
11    },
12    core::{
13        assets,
14        attrs::{component_attributes, named_state_attribute},
15        platform::DomBridge,
16        state::Orientation,
17    },
18    prelude::*,
19    theme::{use_direction, use_stylesheets},
20};
21use std::rc::Rc;
22const BEM: Bem = Bem::new("fc-webview");
23const VISUALLY_HIDDEN: Bem = Bem::new("fc-u-visually-hidden");
24
25/// Where the frame is in loading its page.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum WebviewState {
29    /// Nothing requested yet.
30    #[default]
31    Idle,
32    /// A navigation is in flight.
33    Loading,
34    /// The frame finished a navigation. Not proof of success: a 404 loads too.
35    Loaded,
36    /// The address was refused or the frame failed.
37    Error,
38}
39impl WebviewState {
40    const fn as_str(self) -> &'static str {
41        match self {
42            Self::Idle => "idle",
43            Self::Loading => "loading",
44            Self::Loaded => "loaded",
45            Self::Error => "error",
46        }
47    }
48}
49
50/// Embedded browser chrome around a sandboxed frame. Changing `src` navigates; so does
51/// the address bar, after [`frame_url`] refuses unsafe schemes. With `tabs`, a tab list
52/// sits above the toolbar and reports choices through `ontab`.
53#[component]
54pub fn Webview(
55    /// The frame's title, which names it for assistive technology.
56    title: AccessibleName,
57    /// The page to show; a change navigates.
58    src: String,
59    /// Page tab labels.
60    #[props(default)]
61    tabs: Vec<String>,
62    #[props(default)] selected_tab: usize,
63    #[props(default)] ontab: Option<EventHandler<usize>>,
64    /// The frame's sandbox. Without `allow-same-origin` the page runs in an opaque
65    /// origin and cannot reach this page's cookies or storage; add it knowingly.
66    #[props(default = "allow-scripts allow-forms allow-popups".to_owned())]
67    sandbox: String,
68    /// Replaces the overlay's message after a failed load.
69    #[props(default)]
70    error: Option<String>,
71    #[props(default)] onnavigate: Option<EventHandler<String>>,
72    #[props(default)] onstate: Option<EventHandler<WebviewState>>,
73    #[props(default)] class: Option<String>,
74    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
75) -> Element {
76    // The first page is part of the first render, so server rendering and fixtures show
77    // it; later changes to `src` navigate through the effect below.
78    let first = frame_url(&src);
79    let mut history = use_signal(|| first.clone().map(|url| vec![url]).unwrap_or_default());
80    let mut at = use_signal(|| 0_usize);
81    let mut state = use_signal(|| {
82        if first.is_ok() {
83            WebviewState::Loading
84        } else {
85            WebviewState::Error
86        }
87    });
88    let mut draft = use_signal(|| first.clone().unwrap_or_else(|_| src.clone()));
89    let mut seen = use_signal(|| src.clone());
90    let mut tab_nodes = use_signal(Vec::<(usize, Rc<MountedData>)>::new);
91    let frame_id = use_id();
92    // The selected tab is the parent's when it handles `ontab`, and the control's own
93    // otherwise, so arrows always leave the focused tab as the tab stop.
94    let mut chosen = use_signal(|| selected_tab);
95    let mut seen_tab = use_signal(|| selected_tab);
96    if *seen_tab.peek() != selected_tab {
97        seen_tab.set(selected_tab);
98        chosen.set(selected_tab);
99    }
100    // Same page again: rendering an unchanged `src` would not navigate, so the frame is
101    // told to reload, as upstream's binding reassigns `src`.
102    let mut reload = {
103        let frame_id = frame_id.clone();
104        move || {
105            let frame_id = frame_id.clone();
106            state.set(WebviewState::Loading);
107            if let Some(onstate) = onstate {
108                onstate.call(WebviewState::Loading);
109            }
110            spawn(async move {
111                let _ = DomBridge.reload_frame(&frame_id).await;
112            });
113        }
114    };
115    let mut choose_tab = move |index: usize| {
116        chosen.set(index);
117        if let Some(ontab) = ontab {
118            ontab.call(index);
119        }
120    };
121    let direction = use_direction();
122
123    let mut set_state = move |next: WebviewState| {
124        state.set(next);
125        if let Some(onstate) = onstate {
126            onstate.call(next);
127        }
128    };
129    let mut go = move |raw: String, record: bool| match frame_url(&raw) {
130        Ok(url) => {
131            if record {
132                // A new navigation discards the forward tail, as browser history does.
133                let keep = if history.peek().is_empty() {
134                    0
135                } else {
136                    at() + 1
137                };
138                let mut list = history.write();
139                list.truncate(keep);
140                list.push(url.clone());
141                at.set(list.len() - 1);
142            }
143            draft.set(url.clone());
144            set_state(WebviewState::Loading);
145            if let Some(onnavigate) = onnavigate {
146                onnavigate.call(url);
147            }
148        }
149        // Refused rather than dropped: whoever typed it should learn it did not load.
150        Err(_) => set_state(WebviewState::Error),
151    };
152    use_effect(use_reactive!(|src| {
153        if *seen.peek() != src {
154            seen.set(src.clone());
155            go(src, true);
156        }
157    }));
158
159    let current = history.read().get(at()).cloned().unwrap_or_default();
160    let can_back = at() > 0;
161    let can_forward = at() + 1 < history.read().len();
162    let mut step = move |forward: bool| {
163        let target = if forward {
164            at() + 1
165        } else {
166            at().saturating_sub(1)
167        };
168        let url = history.peek().get(target).cloned();
169        if let Some(url) = url {
170            at.set(target);
171            go(url, false);
172        }
173    };
174
175    let mut attributes = component_attributes(
176        attributes,
177        ClassList::new(BEM.block()).extend(class.as_deref()),
178        &[],
179    );
180    attributes.push(named_state_attribute(state().as_str()));
181    let secure = is_secure(&current);
182    let failed = state() == WebviewState::Error;
183    let tab_count = tabs.len();
184    let nav_class = BEM.element("nav");
185    let back_class = ClassList::new(&nav_class)
186        .with(BEM.element("nav--back"))
187        .to_string();
188
189    rsx! {
190        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_WEBVIEW_CSS } }
191        div { ..attributes,
192            if !tabs.is_empty() {
193                div { class: BEM.element("tabs"), role: "tablist", aria_label: "Pages",
194                    for (index, tab) in tabs.iter().enumerate() {
195                        button { key: "{index}", r#type: "button", class: BEM.element("tab"), role: "tab",
196                            aria_selected: (index == chosen()).to_string(),
197                            tabindex: if index == chosen() { "0" } else { "-1" },
198                            onmounted: move |event: MountedEvent| {
199                                let mut nodes = tab_nodes.write();
200                                nodes.retain(|(i, _)| *i != index);
201                                nodes.push((index, event.data()));
202                            },
203                            onclick: move |_| choose_tab(index),
204                            onkeydown: move |event: KeyboardEvent| {
205                                let Some(step) = roving::intent(&event.key(), Orientation::Horizontal, direction) else { return };
206                                let Some(next) = roving::target(tab_count, index, step, |_| true) else { return };
207                                event.prevent_default();
208                                // Automatic activation: the page follows the focused tab.
209                                choose_tab(next);
210                                let node = tab_nodes.peek().iter().find(|(i, _)| *i == next).map(|(_, n)| n.clone());
211                                if let Some(node) = node {
212                                    spawn(async move { let _ = node.set_focus(true).await; });
213                                }
214                            },
215                            span { class: BEM.element("tab-label"), "{tab}" }
216                        }
217                    }
218                }
219            }
220            div { class: BEM.element("toolbar"),
221                // Disabled rather than hidden, so the toolbar never shifts under the pointer.
222                Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Back"), class: back_class,
223                    availability: if can_back { Availability::Enabled } else { Availability::Disabled },
224                    onclick: move |_| step(false), "\u{2190}" }
225                Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Forward"), class: nav_class.clone(),
226                    availability: if can_forward { Availability::Enabled } else { Availability::Disabled },
227                    onclick: move |_| step(true), "\u{2192}" }
228                Button { variant: ButtonVariant::Ghost, size: ButtonSize::Sm, icon_label: name!("Reload"), class: nav_class,
229                    availability: if current.is_empty() { Availability::Disabled } else { Availability::Enabled },
230                    onclick: { let mut reload = reload.clone(); move |_| reload() }, "\u{21bb}" }
231                // A real form, so Enter submits and mobile keyboards offer "Go".
232                form { class: BEM.element("address"), role: "search",
233                    onsubmit: move |event: FormEvent| {
234                        event.prevent_default();
235                        go(draft(), true);
236                    },
237                    match secure {
238                        Some(true) => rsx! {
239                            span { class: BEM.element("secure"),
240                                span { aria_hidden: "true", "\u{1f512}" }
241                                span { class: VISUALLY_HIDDEN.block(), "Secure" }
242                            }
243                        },
244                        // The warning is spoken and shown: colour alone would hide it.
245                        Some(false) => rsx! {
246                            span { class: BEM.element("secure"), ..crate::spread![named_state_attribute("insecure")],
247                                span { aria_hidden: "true", "\u{26a0}" }
248                                span { "Not secure" }
249                            }
250                        },
251                        None => rsx! {},
252                    }
253                    input { class: BEM.element("address-input"), r#type: "text", aria_label: "Address",
254                        autocomplete: "off", spellcheck: "false", value: "{draft}",
255                        oninput: move |event| draft.set(event.value()) }
256                }
257            }
258            // An empty src would load the host page itself into the frame; nothing loaded
259            // yet, or a refused first address, is about:blank.
260            iframe { id: "{frame_id}", class: BEM.element("frame"), title: "{title}",
261                src: if current.is_empty() { "about:blank".to_owned() } else { current.clone() }, "sandbox": "{sandbox}",
262                // The host's URL is not the embedded page's business.
263                referrerpolicy: "strict-origin-when-cross-origin",
264                // No camera, microphone, or location unless the host grants them.
265                allow: "",
266                onload: move |_| if !current_failed(state) { set_state(WebviewState::Loaded) },
267            }
268            div { class: BEM.element("overlay"), role: "alert", hidden: !failed,
269                if failed {
270                    p { class: BEM.element("error"), {error.clone().unwrap_or_else(|| "This page could not be loaded.".to_owned())} }
271                    Button { variant: ButtonVariant::Secondary, size: ButtonSize::Sm,
272                        onclick: move |_| {
273                            // The address bar may still hold the refused address; show the
274                            // page that is actually being reloaded.
275                            let shown = history.peek().get(at()).cloned();
276                            if let Some(url) = shown {
277                                draft.set(url);
278                                reload();
279                            }
280                        },
281                        "Retry"
282                    }
283                }
284            }
285        }
286    }
287}
288
289/// A refused address leaves the old page in the frame, and its load must not clear the
290/// error the refusal raised.
291fn current_failed(state: Signal<WebviewState>) -> bool {
292    *state.peek() == WebviewState::Error
293}