Skip to main content

gpui_cn/webview/
browser.rs

1//! A browser: a tab strip, back, forward and reload buttons, and an
2//! address field over native web views.
3
4use gpui_kit::{
5    App, AppContext as _, Context, ElementId, Entity, EventEmitter, Focusable as _,
6    InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _,
7    Subscription, Task, TestSupportExt as _, Window,
8    base::{h_flex, v_flex},
9    div,
10    prelude::FluentBuilder as _,
11};
12use std::sync::Arc;
13
14use smol::channel::{Sender, unbounded};
15
16use super::{WebFrame, WebView};
17use crate::{
18    ActiveTheme as _, Button, Input, InputEvent, InputState, Tab, Tabs, TabsEvent, TabsState,
19    gpui_kit::assets::IconName,
20};
21
22/// The element id the browser's parts derive theirs from.
23const ID: &str = "browser";
24
25/// The most tabs a browser holds. A page that opens windows in a loop
26/// stops here instead of exhausting native views.
27const MAX_TABS: usize = 32;
28
29/// The search the address field falls back to for text that is not an
30/// address.
31const SEARCH: &str = "https://duckduckgo.com/?q=";
32
33/// What a page tells the browser from inside its native callbacks, which
34/// have no context to update the state with.
35enum Notice {
36    Url(SharedString, String),
37    Title(SharedString, String),
38    OpenTab(String),
39}
40
41struct Page {
42    id: SharedString,
43    view: Entity<WebView>,
44    url: SharedString,
45}
46
47/// Something the browser did that its host may want to know.
48#[derive(Clone, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum BrowserEvent {
51    /// A tab opened. The payload is its id.
52    Opened(SharedString),
53    /// A tab closed. The payload is its id.
54    Closed(SharedString),
55    /// A tab started to load an address.
56    Navigated {
57        /// The id of the tab.
58        tab: SharedString,
59        /// The address it navigates to.
60        url: SharedString,
61    },
62    /// A tab could not open, such as at the tab limit or when the platform
63    /// refused a native view. The payload is the reason.
64    Failed(SharedString),
65}
66
67/// A browser: a tab strip, back, forward and reload buttons, an address
68/// field, and the selected tab's page in a [`WebFrame`].
69///
70/// It is a view that fills its parent: open it with
71/// [`BrowserState::open`] and place the entity as a child. It repaints
72/// itself when a tab opens, closes or changes. It always has at least one
73/// tab, since closing the last one opens the home page again. The pages
74/// are native views, so the same limits as [`WebView`] apply.
75pub struct BrowserState {
76    tabs: Entity<TabsState>,
77    address: Entity<InputState>,
78    pages: Vec<Page>,
79    home: SharedString,
80    next: u64,
81    notices: Sender<Notice>,
82    _pump: Task<()>,
83    _subscriptions: Vec<Subscription>,
84}
85
86impl EventEmitter<BrowserEvent> for BrowserState {}
87
88impl BrowserState {
89    /// A browser with one tab on `home`, which a new tab opens as well.
90    ///
91    /// # Errors
92    ///
93    /// If the platform cannot create the first native web view. See
94    /// [`WebView::build`] for the setup each platform needs.
95    pub fn open(
96        home: impl Into<SharedString>,
97        window: &mut Window,
98        cx: &mut App,
99    ) -> wry::Result<Entity<Self>> {
100        let home = home.into();
101        let state = cx.new(|cx| Self::create(home.clone(), window, cx));
102        state.update(cx, |this, cx| this.open_tab(home.to_string(), window, cx))?;
103        Ok(state)
104    }
105
106    fn create(home: SharedString, window: &mut Window, cx: &mut Context<Self>) -> Self {
107        let tabs = cx.new(|_| TabsState::new([]));
108        let address =
109            cx.new(|cx| InputState::new(window, cx).placeholder("Search or enter address"));
110        let (notices, inbox) = unbounded();
111        let pump = cx.spawn_in(window, async move |this, cx| {
112            while let Ok(notice) = inbox.recv().await {
113                if this
114                    .update_in(cx, |this, window, cx| this.apply(notice, window, cx))
115                    .is_err()
116                {
117                    break;
118                }
119            }
120        });
121        let subscriptions = vec![
122            cx.subscribe_in(&tabs, window, |this, _, event, window, cx| match event {
123                TabsEvent::Selected(_) => this.show_selected(window, cx),
124                TabsEvent::Closed(id) => this.drop_page(id, window, cx),
125                TabsEvent::AddRequested => {
126                    this.open_home(window, cx);
127                }
128                _ => {}
129            }),
130            cx.subscribe_in(&address, window, |this, input, event, _, cx| {
131                if matches!(event, InputEvent::PressEnter { .. }) {
132                    let text = input.read(cx).value().to_string();
133                    this.navigate(text, cx);
134                }
135            }),
136        ];
137        Self {
138            tabs,
139            address,
140            pages: Vec::new(),
141            home,
142            next: 0,
143            notices,
144            _pump: pump,
145            _subscriptions: subscriptions,
146        }
147    }
148
149    /// Opens a tab on the home page, or reports why it could not.
150    fn open_home(&mut self, window: &mut Window, cx: &mut Context<Self>) {
151        let home = self.home.to_string();
152        self.open_reporting(home, window, cx);
153    }
154
155    /// Opens a tab on `url`, or emits [`BrowserEvent::Failed`].
156    fn open_reporting(&mut self, url: String, window: &mut Window, cx: &mut Context<Self>) {
157        if let Err(error) = self.open_tab(url, window, cx) {
158            cx.emit(BrowserEvent::Failed(error.to_string().into()));
159        }
160    }
161
162    /// Opens a tab on `url` and selects it. Returns its id.
163    ///
164    /// # Errors
165    ///
166    /// If the browser holds 32 tabs, or the platform cannot
167    /// create the native web view.
168    pub fn open_tab(
169        &mut self,
170        url: impl Into<String>,
171        window: &mut Window,
172        cx: &mut Context<Self>,
173    ) -> wry::Result<SharedString> {
174        let url = url.into();
175        if self.pages.len() >= MAX_TABS {
176            return Err(wry::Error::Io(std::io::Error::other("too many tabs")));
177        }
178        let id = SharedString::from(format!("tab-{}", self.next));
179        self.next += 1;
180        let navigation = self.notices.clone();
181        let titles = self.notices.clone();
182        let windows = self.notices.clone();
183        let tab = id.clone();
184        let named = id.clone();
185        let builder = wry::WebViewBuilder::new()
186            .with_url(url.clone())
187            .with_navigation_handler(move |url| {
188                if !is_browsable(&url) {
189                    return false;
190                }
191                navigation.try_send(Notice::Url(tab.clone(), url)).ok();
192                true
193            })
194            .with_document_title_changed_handler(move |title| {
195                titles.try_send(Notice::Title(named.clone(), title)).ok();
196            })
197            .with_new_window_req_handler(move |url, _| {
198                windows.try_send(Notice::OpenTab(url)).ok();
199                wry::NewWindowResponse::Deny
200            });
201        let view = WebView::build(builder, window, cx)?;
202        let view = cx.new(|_| view);
203        self.pages.push(Page {
204            id: id.clone(),
205            view,
206            url: url.into(),
207        });
208        self.tabs.update(cx, |tabs, cx| {
209            tabs.push(
210                Tab::new(id.clone(), "New tab").with_icon(IconName::Globe),
211                cx,
212            );
213        });
214        self.show_selected(window, cx);
215        cx.emit(BrowserEvent::Opened(id.clone()));
216        Ok(id)
217    }
218
219    /// Navigates the selected tab. Text with a scheme loads as it is, text
220    /// that looks like a host gets `https://`, and anything else is a web
221    /// search.
222    pub fn navigate(&mut self, text: impl AsRef<str>, cx: &mut Context<Self>) {
223        let url = address_to_url(text.as_ref());
224        let Some(page) = self.selected_page(cx) else {
225            return;
226        };
227        page.view.update(cx, |view, _| view.load_url(&url));
228        let tab = page.id.clone();
229        cx.emit(BrowserEvent::Navigated {
230            tab,
231            url: url.into(),
232        });
233    }
234
235    /// Goes back in the selected tab's history.
236    pub fn back(&mut self, cx: &mut Context<Self>) {
237        if let Some(page) = self.selected_page(cx) {
238            page.view.update(cx, |view, _| view.back().ok());
239        }
240    }
241
242    /// Goes forward in the selected tab's history.
243    pub fn forward(&mut self, cx: &mut Context<Self>) {
244        if let Some(page) = self.selected_page(cx) {
245            page.view.update(cx, |view, _| view.forward().ok());
246        }
247    }
248
249    /// Reloads the selected tab.
250    pub fn reload(&mut self, cx: &mut Context<Self>) {
251        if let Some(page) = self.selected_page(cx) {
252            page.view.read(cx).raw().reload().ok();
253        }
254    }
255
256    /// Closes the tab with `id`.
257    pub fn close_tab(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
258        self.tabs.update(cx, |tabs, cx| tabs.remove(id, cx));
259    }
260
261    /// The tab strip's state, to observe or to select a tab.
262    pub fn tabs(&self) -> &Entity<TabsState> {
263        &self.tabs
264    }
265
266    /// The address of the selected tab, if there is one.
267    pub fn url(&self, cx: &App) -> Option<SharedString> {
268        let selected = self.tabs.read(cx).selected()?;
269        self.pages
270            .iter()
271            .find(|page| &page.id == selected)
272            .map(|page| page.url.clone())
273    }
274
275    fn selected_page(&self, cx: &App) -> Option<&Page> {
276        let selected = self.tabs.read(cx).selected()?;
277        self.pages.iter().find(|page| &page.id == selected)
278    }
279
280    /// Shows the selected tab's native view, hides the others, and shows
281    /// the selected tab's address.
282    fn show_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
283        let selected = self.tabs.read(cx).selected().cloned();
284        for page in &self.pages {
285            let shown = selected.as_ref() == Some(&page.id);
286            page.view.read(cx).set_visible(shown).ok();
287        }
288        let url = self.url(cx).unwrap_or_default();
289        self.address
290            .update(cx, |input, cx| input.set_value(url.to_string(), window, cx));
291        cx.notify();
292    }
293
294    fn drop_page(&mut self, id: &SharedString, window: &mut Window, cx: &mut Context<Self>) {
295        let Some(index) = self.pages.iter().position(|page| &page.id == id) else {
296            return;
297        };
298        self.pages.remove(index);
299        cx.emit(BrowserEvent::Closed(id.clone()));
300        if self.pages.is_empty() {
301            self.open_home(window, cx);
302        }
303    }
304
305    fn apply(&mut self, notice: Notice, window: &mut Window, cx: &mut Context<Self>) {
306        match notice {
307            Notice::Url(id, url) => {
308                let selected = self.tabs.read(cx).selected() == Some(&id);
309                let Some(page) = self.pages.iter_mut().find(|page| page.id == id) else {
310                    return;
311                };
312                page.url = url.clone().into();
313                let editing = self.address.read(cx).focus_handle(cx).is_focused(window);
314                if selected && !editing {
315                    self.address
316                        .update(cx, |input, cx| input.set_value(url, window, cx));
317                }
318            }
319            Notice::Title(id, title) => {
320                let label = if title.trim().is_empty() {
321                    SharedString::from("New tab")
322                } else {
323                    title.into()
324                };
325                self.tabs.update(cx, |tabs, cx| tabs.rename(id, label, cx));
326            }
327            Notice::OpenTab(url) => {
328                if is_browsable(&url) {
329                    self.open_reporting(url, window, cx);
330                }
331            }
332        }
333        cx.notify();
334    }
335}
336
337/// Whether the browser loads `url`: web pages and `about:blank`. Files,
338/// scripts, data and custom schemes stay out of a page's reach and out of
339/// the address field's.
340fn is_browsable(url: &str) -> bool {
341    let lower = url.trim().to_ascii_lowercase();
342    lower.starts_with("https://") || lower.starts_with("http://") || lower == "about:blank"
343}
344
345/// Whether `text` names a host on this machine or a network address, which
346/// is served over plain HTTP in development.
347fn is_local_host(text: &str) -> bool {
348    let host = text.split(['/', '?', '#']).next().unwrap_or_default();
349    let name = host.rsplit_once(':').map_or(host, |(name, _)| name);
350    name == "localhost" || name.parse::<std::net::Ipv4Addr>().is_ok()
351}
352
353/// The address a line typed in the address field stands for.
354fn address_to_url(text: &str) -> String {
355    let text = text.trim();
356    if is_browsable(text) {
357        text.to_owned()
358    } else if !text.contains(char::is_whitespace) && is_local_host(text) {
359        format!("http://{text}")
360    } else if !text.contains(char::is_whitespace) && !text.contains(':') && text.contains('.') {
361        format!("https://{text}")
362    } else {
363        let query: String = text
364            .bytes()
365            .map(|byte| match byte {
366                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
367                    char::from(byte).to_string()
368                }
369                b' ' => "+".to_owned(),
370                _ => format!("%{byte:02X}"),
371            })
372            .collect();
373        format!("{SEARCH}{query}")
374    }
375}
376
377impl Render for BrowserState {
378    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
379        let page = self.selected_page(cx).map(|page| page.view.clone());
380        let (fill, bar, hairline) = {
381            let theme = cx.theme();
382            (theme.background(), theme.metrics.title_bar, theme.border())
383        };
384        let button = |name: &'static str, icon: IconName, label: &'static str| {
385            Button::new(ElementId::NamedChild(
386                Arc::new(ElementId::Name(ID.into())),
387                name.into(),
388            ))
389            .ghost()
390            .icon(icon)
391            .accessibility_label(label)
392            .tooltip(label)
393        };
394        v_flex()
395            .size_full()
396            .id(ID)
397            .test_support()
398            .bg(fill)
399            .child(div().h(bar).w_full().flex_shrink_0().child(Tabs::new(
400                ElementId::NamedChild(Arc::new(ElementId::Name(ID.into())), "tabs".into()),
401                &self.tabs,
402            )))
403            .child(
404                h_flex()
405                    .gap_1()
406                    .p_2()
407                    .flex_shrink_0()
408                    .border_b_1()
409                    .border_color(hairline)
410                    .items_center()
411                    .child(
412                        button("back", IconName::ArrowLeft, "Back")
413                            .on_click(cx.listener(|this, _, _, cx| this.back(cx))),
414                    )
415                    .child(
416                        button("forward", IconName::ArrowRight, "Forward")
417                            .on_click(cx.listener(|this, _, _, cx| this.forward(cx))),
418                    )
419                    .child(
420                        button("reload", IconName::RotateCw, "Reload")
421                            .on_click(cx.listener(|this, _, _, cx| this.reload(cx))),
422                    )
423                    .child(
424                        Input::new(&self.address)
425                            .id(ElementId::NamedChild(
426                                Arc::new(ElementId::Name(ID.into())),
427                                "address".into(),
428                            ))
429                            .accessibility_label("Address")
430                            .flex_1(),
431                    ),
432            )
433            .when_some(page, |column, page| {
434                column.child(
435                    div().flex_1().min_h_0().child(
436                        WebFrame::new(ElementId::NamedChild(
437                            Arc::new(ElementId::Name(ID.into())),
438                            "page".into(),
439                        ))
440                        .webview(page)
441                        .size_full()
442                        .rounded_none()
443                        .border_0(),
444                    ),
445                )
446            })
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::{address_to_url, is_browsable};
453
454    #[test]
455    fn a_web_address_keeps_its_scheme() {
456        assert_eq!(address_to_url(" https://a.dev/x "), "https://a.dev/x");
457        assert_eq!(address_to_url("about:blank"), "about:blank");
458    }
459
460    #[test]
461    fn a_host_gets_https() {
462        assert_eq!(address_to_url("example.com/a"), "https://example.com/a");
463    }
464
465    #[test]
466    fn a_local_host_gets_http() {
467        assert_eq!(address_to_url("localhost:3000"), "http://localhost:3000");
468        assert_eq!(
469            address_to_url("127.0.0.1:8080/x"),
470            "http://127.0.0.1:8080/x"
471        );
472    }
473
474    #[test]
475    fn other_text_is_a_search() {
476        assert_eq!(
477            address_to_url("rust gpui?"),
478            "https://duckduckgo.com/?q=rust+gpui%3F"
479        );
480    }
481
482    #[test]
483    fn files_scripts_and_data_are_never_loaded() {
484        for text in [
485            "file:///etc/passwd",
486            "javascript:alert(1)",
487            "data:text/html,x",
488        ] {
489            assert!(!is_browsable(text), "{text}");
490            assert!(
491                address_to_url(text).starts_with("https://duckduckgo.com/?q="),
492                "{text}"
493            );
494        }
495    }
496}