Skip to main content

guise/
webview.rs

1//! `WebView` — a native web view embedded in a gpui window (stateful entity).
2//!
3//! Backed by [`wry`](https://crates.io/crates/wry), which parents a real OS
4//! web view (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) as a
5//! child of the gpui window. The native view is positioned every frame to track
6//! the bounds of this component, so it composes inside normal `guise` layout.
7//!
8//! Create with `cx.new(|cx| WebView::new(cx).url("https://example.com"))` and
9//! subscribe for [`WebViewEvent`]s. Because the underlying view owns OS
10//! resources, it is built lazily on first render (when a window handle exists).
11//!
12//! The native backend lives behind the default-on `webview` feature. Disable it
13//! (`default-features = false`) for headless or docs-only builds; the component
14//! then renders a themed placeholder while keeping the same public API.
15
16use gpui::prelude::*;
17use gpui::{div, px, Context, EventEmitter, FocusHandle, IntoElement, SharedString, Window};
18
19use crate::devtools::Probed;
20use crate::theme::{theme, Size};
21
22#[cfg(feature = "webview")]
23use {
24    gpui::{canvas, Bounds, Pixels},
25    std::{cell::RefCell, rc::Rc, time::Duration},
26    wry::{
27        dpi::{LogicalPosition, LogicalSize},
28        PageLoadEvent, Rect, WebViewBuilder,
29    },
30};
31
32/// Emitted as the embedded page loads and changes.
33#[derive(Debug, Clone)]
34pub enum WebViewEvent {
35    /// The document title changed. Carries the new title.
36    TitleChanged(SharedString),
37    /// The view navigated to a new URL. Carries the destination.
38    UrlChanged(SharedString),
39    /// A page began loading.
40    LoadStarted,
41    /// A page finished loading.
42    LoadFinished,
43    /// The page posted a message to the host via `window.ipc.postMessage(...)`.
44    /// Carries the raw string payload; the host decides how to interpret it.
45    Message(SharedString),
46}
47
48/// What the view should display.
49#[derive(Clone)]
50enum Source {
51    /// Nothing requested yet.
52    Empty,
53    /// Load a remote or local URL.
54    Url(SharedString),
55    /// Load an inline HTML string.
56    Html(SharedString),
57}
58
59/// A native web view. Create with `cx.new(|cx| WebView::new(cx))`.
60pub struct WebView {
61    source: Source,
62    focus: FocusHandle,
63    radius: Option<Size>,
64    bordered: bool,
65    transparent: bool,
66    width: Option<f32>,
67    height: Option<f32>,
68    /// JavaScript injected at document start (before page scripts run). Hosts
69    /// use it to expose a native API the page can call via
70    /// `window.ipc.postMessage(...)`. Only applied when the `webview` feature is
71    /// on; the placeholder ignores it.
72    #[cfg_attr(not(feature = "webview"), allow(dead_code))]
73    init_script: Option<SharedString>,
74    /// A directory served over an internal `guise://` origin (see [`WebView::serve`]).
75    #[cfg_attr(not(feature = "webview"), allow(dead_code))]
76    serve_dir: Option<std::path::PathBuf>,
77
78    #[cfg(feature = "webview")]
79    inner: Option<Rc<wry::WebView>>,
80    #[cfg(feature = "webview")]
81    queue: Rc<RefCell<Vec<WebViewEvent>>>,
82    #[cfg(feature = "webview")]
83    draining: bool,
84}
85
86impl EventEmitter<WebViewEvent> for WebView {}
87
88impl WebView {
89    pub fn new(cx: &mut Context<Self>) -> Self {
90        WebView {
91            source: Source::Empty,
92            focus: cx.focus_handle(),
93            radius: None,
94            bordered: true,
95            transparent: false,
96            width: None,
97            height: None,
98            init_script: None,
99            serve_dir: None,
100
101            #[cfg(feature = "webview")]
102            inner: None,
103            #[cfg(feature = "webview")]
104            queue: Rc::new(RefCell::new(Vec::new())),
105            #[cfg(feature = "webview")]
106            draining: false,
107        }
108    }
109
110    /// Inject JavaScript that runs at document start, before the page's own
111    /// scripts. Combined with [`WebViewEvent::Message`] (delivered when the page
112    /// calls `window.ipc.postMessage(str)`), this lets a host expose a native
113    /// API to the embedded page. No-op under the placeholder build.
114    pub fn init_script(mut self, js: impl Into<SharedString>) -> Self {
115        self.init_script = Some(js.into());
116        self
117    }
118
119    /// Load a URL (`https://…`, `file://…`, etc.).
120    pub fn url(mut self, url: impl Into<SharedString>) -> Self {
121        self.source = Source::Url(url.into());
122        self
123    }
124
125    /// Load an inline HTML document.
126    pub fn html(mut self, html: impl Into<SharedString>) -> Self {
127        self.source = Source::Html(html.into());
128        self
129    }
130
131    /// Serve files from `dir` over an internal `guise://localhost/` origin and
132    /// load `entry` from it. Prefer this over a `file://` [`WebView::url`] for
133    /// local content: `file://` pages are treated as an opaque/null origin, so
134    /// the JS bridge (`window.ipc.postMessage`) is dropped and ES modules /
135    /// `fetch` are blocked. A real origin fixes both.
136    pub fn serve(mut self, dir: impl Into<std::path::PathBuf>, entry: impl AsRef<str>) -> Self {
137        self.serve_dir = Some(dir.into());
138        self.source = Source::Url(
139            format!(
140                "guise://localhost/{}",
141                entry.as_ref().trim_start_matches('/')
142            )
143            .into(),
144        );
145        self
146    }
147
148    /// Override the corner radius (defaults to the theme radius).
149    pub fn radius(mut self, radius: Size) -> Self {
150        self.radius = Some(radius);
151        self
152    }
153
154    /// Draw a border + rounded frame around the view (default `true`).
155    pub fn bordered(mut self, bordered: bool) -> Self {
156        self.bordered = bordered;
157        self
158    }
159
160    /// Let the page background show through (default `false`).
161    pub fn transparent(mut self, transparent: bool) -> Self {
162        self.transparent = transparent;
163        self
164    }
165
166    /// Fix the width in pixels. Defaults to filling the parent.
167    pub fn width(mut self, width: f32) -> Self {
168        self.width = Some(width);
169        self
170    }
171
172    /// Fix the height in pixels. Defaults to filling the parent.
173    pub fn height(mut self, height: f32) -> Self {
174        self.height = Some(height);
175        self
176    }
177
178    /// Navigate the live view to `url`, updating the stored source.
179    pub fn load_url(&mut self, url: impl Into<SharedString>, cx: &mut Context<Self>) {
180        let url = url.into();
181        #[cfg(feature = "webview")]
182        if let Some(inner) = &self.inner {
183            let _ = inner.load_url(&url);
184        }
185        self.source = Source::Url(url);
186        cx.notify();
187    }
188
189    /// Replace the live view with inline HTML, updating the stored source.
190    pub fn load_html(&mut self, html: impl Into<SharedString>, cx: &mut Context<Self>) {
191        let html = html.into();
192        #[cfg(feature = "webview")]
193        if let Some(inner) = &self.inner {
194            let _ = inner.load_html(&html);
195        }
196        self.source = Source::Html(html);
197        cx.notify();
198    }
199
200    /// Run JavaScript in the live view. No-op until the view exists.
201    pub fn evaluate_script(&self, _js: &str) {
202        #[cfg(feature = "webview")]
203        if let Some(inner) = &self.inner {
204            let _ = inner.evaluate_script(_js);
205        }
206    }
207
208    /// Show or hide the native surface. The surface tracks its layout bounds only
209    /// while it is painted, so a host that stops rendering this view (e.g. a
210    /// collapsed drawer or a hidden tab) must hide it explicitly — otherwise the
211    /// OS view lingers on screen at its last position. A painted view re-shows
212    /// itself. No-op until the view exists.
213    pub fn set_visible(&mut self, _visible: bool) {
214        #[cfg(feature = "webview")]
215        if let Some(inner) = &self.inner {
216            let _ = inner.set_visible(_visible);
217        }
218    }
219
220    /// Build the native view once a window handle is available, then start the
221    /// loop that drains events from the wry handlers back onto the entity.
222    #[cfg(feature = "webview")]
223    fn ensure_view(&mut self, window: &mut Window, cx: &mut Context<Self>, bounds: Bounds<Pixels>) {
224        if self.inner.is_some() {
225            return;
226        }
227
228        let queue = self.queue.clone();
229        let (q_title, q_nav, q_load, q_ipc) =
230            (queue.clone(), queue.clone(), queue.clone(), queue.clone());
231
232        let mut builder = WebViewBuilder::new()
233            .with_bounds(rect_from(bounds))
234            .with_transparent(self.transparent)
235            .with_document_title_changed_handler(move |title| {
236                q_title
237                    .borrow_mut()
238                    .push(WebViewEvent::TitleChanged(title.into()));
239            })
240            .with_navigation_handler(move |url| {
241                q_nav
242                    .borrow_mut()
243                    .push(WebViewEvent::UrlChanged(url.into()));
244                true
245            })
246            .with_on_page_load_handler(move |event, _url| {
247                q_load.borrow_mut().push(match event {
248                    PageLoadEvent::Started => WebViewEvent::LoadStarted,
249                    PageLoadEvent::Finished => WebViewEvent::LoadFinished,
250                });
251            })
252            // JS -> native: `window.ipc.postMessage(str)` in the page lands here.
253            .with_ipc_handler(move |req| {
254                q_ipc
255                    .borrow_mut()
256                    .push(WebViewEvent::Message(req.into_body().into()));
257            });
258
259        if let Some(js) = &self.init_script {
260            builder = builder.with_initialization_script(js.to_string());
261        }
262
263        // Serve `serve_dir` over the `guise://` scheme used by `WebView::serve`.
264        if let Some(dir) = self.serve_dir.clone() {
265            builder = builder.with_custom_protocol("guise".to_string(), move |_id, request| {
266                serve_local(&dir, request.uri().path())
267            });
268        }
269
270        builder = match &self.source {
271            Source::Url(url) => builder.with_url(url.as_ref()),
272            Source::Html(html) => builder.with_html(html.as_ref()),
273            Source::Empty => builder,
274        };
275
276        match builder.build_as_child(&*window) {
277            Ok(view) => self.inner = Some(Rc::new(view)),
278            Err(err) => {
279                eprintln!("guise: failed to create webview: {err}");
280                return;
281            }
282        }
283
284        if !self.draining {
285            self.draining = true;
286            cx.spawn(async move |this, cx| loop {
287                cx.background_executor()
288                    .timer(Duration::from_millis(40))
289                    .await;
290                let drained: Vec<WebViewEvent> = queue.borrow_mut().drain(..).collect();
291                let pushed = this.update(cx, |_this, cx| {
292                    let any = !drained.is_empty();
293                    for event in drained {
294                        cx.emit(event);
295                    }
296                    if any {
297                        cx.notify();
298                    }
299                });
300                if pushed.is_err() {
301                    break;
302                }
303            })
304            .detach();
305        }
306    }
307}
308
309#[cfg(feature = "webview")]
310fn rect_from(bounds: Bounds<Pixels>) -> Rect {
311    Rect {
312        position: LogicalPosition::new(bounds.origin.x.to_f64(), bounds.origin.y.to_f64()).into(),
313        size: LogicalSize::new(bounds.size.width.to_f64(), bounds.size.height.to_f64()).into(),
314    }
315}
316
317impl Render for WebView {
318    #[cfg(feature = "webview")]
319    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
320        // Build the native view on the first frame that has a window handle.
321        // It is created at a best-guess size; the `canvas` paint below snaps it
322        // to the real layout bounds on this same frame.
323        if self.inner.is_none() {
324            let w = self.width.unwrap_or(800.0);
325            let h = self.height.unwrap_or(600.0);
326            let initial = Bounds {
327                origin: gpui::point(px(0.0), px(0.0)),
328                size: gpui::size(px(w), px(h)),
329            };
330            self.ensure_view(window, cx, initial);
331        }
332
333        let t = theme(cx);
334        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
335        let border = t.border().hsla();
336        let bg = t.surface().hsla();
337
338        // Sized region the native view tracks. `canvas` hands us the painted
339        // bounds in window coordinates each frame; we forward them to wry.
340        let view = self.inner.clone();
341        let surface = canvas(
342            move |_bounds, _window, _app| {},
343            move |bounds, _state, _window, _app| {
344                if let Some(view) = &view {
345                    let _ = view.set_bounds(rect_from(bounds));
346                    // Being painted means we're on screen; re-assert visibility so
347                    // a view that was hidden while unmounted shows again.
348                    let _ = view.set_visible(true);
349                }
350            },
351        )
352        .size_full();
353
354        frame(self.bordered, radius, border, bg, self.width, self.height)
355            .track_focus(&self.focus)
356            .child(surface)
357            .probe("WebView")
358    }
359
360    #[cfg(not(feature = "webview"))]
361    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
362        let t = theme(cx);
363        let radius = t.radius(self.radius.unwrap_or(t.default_radius));
364        let border = t.border().hsla();
365        let bg = t.surface().hsla();
366        let dimmed = t.dimmed().hsla();
367        let label = match &self.source {
368            Source::Url(url) => url.clone(),
369            Source::Html(html) => SharedString::from(format!("inline HTML ({} bytes)", html.len())),
370            Source::Empty => SharedString::from("no source"),
371        };
372
373        frame(self.bordered, radius, border, bg, self.width, self.height)
374            .track_focus(&self.focus)
375            .items_center()
376            .justify_center()
377            .text_color(dimmed)
378            .child(SharedString::from(format!("WebView (disabled): {label}")))
379            .probe("WebView")
380            .attr("source", label)
381    }
382}
383
384/// The themed container shared by both render paths.
385fn frame(
386    bordered: bool,
387    radius: f32,
388    border: gpui::Hsla,
389    bg: gpui::Hsla,
390    width: Option<f32>,
391    height: Option<f32>,
392) -> gpui::Stateful<gpui::Div> {
393    let mut root = div().id("guise-webview").flex().overflow_hidden().bg(bg);
394    root = match width {
395        Some(w) => root.w(px(w)),
396        None => root.w_full(),
397    };
398    root = match height {
399        Some(h) => root.h(px(h)),
400        None => root.h_full(),
401    };
402    if bordered {
403        root = root.border_1().border_color(border).rounded(px(radius));
404    }
405    root
406}
407
408/// Serve a file from `dir` for a `guise://localhost/<path>` request. Rejects
409/// paths that try to escape `dir`; unknown files return 404.
410#[cfg(feature = "webview")]
411fn serve_local(
412    dir: &std::path::Path,
413    url_path: &str,
414) -> wry::http::Response<std::borrow::Cow<'static, [u8]>> {
415    use std::borrow::Cow;
416    use wry::http::{Response, StatusCode};
417
418    // Built by hand rather than through `Response::builder().….unwrap()`:
419    // this runs on wry's request thread, where a panic takes the process with
420    // it, and there is no reason for serving a static 404 to be fallible.
421    let not_found = || {
422        let mut response = Response::new(Cow::Borrowed(&b"not found"[..]));
423        *response.status_mut() = StatusCode::NOT_FOUND;
424        response
425    };
426
427    let rel = url_path.trim_start_matches('/');
428    let rel = if rel.is_empty() { "index.html" } else { rel };
429    // No traversal or absolute escapes; only simple forward paths.
430    if rel
431        .split('/')
432        .any(|c| c.is_empty() || c == "." || c == "..")
433    {
434        return not_found();
435    }
436    match std::fs::read(dir.join(rel)) {
437        Ok(bytes) => {
438            let mut response = Response::new(Cow::Owned(bytes));
439            // Both header values are crate constants, so neither can fail to
440            // parse; inserting them directly keeps that fact local.
441            let headers = response.headers_mut();
442            headers.insert(
443                wry::http::header::CONTENT_TYPE,
444                wry::http::HeaderValue::from_static(content_type(rel)),
445            );
446            headers.insert(
447                wry::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
448                wry::http::HeaderValue::from_static("*"),
449            );
450            response
451        }
452        Err(_) => not_found(),
453    }
454}
455
456/// A best-effort content type from a file's extension.
457#[cfg(feature = "webview")]
458fn content_type(rel: &str) -> &'static str {
459    match rel.rsplit('.').next() {
460        Some("html" | "htm") => "text/html; charset=utf-8",
461        Some("js" | "mjs") => "text/javascript; charset=utf-8",
462        Some("css") => "text/css; charset=utf-8",
463        Some("json") => "application/json; charset=utf-8",
464        Some("svg") => "image/svg+xml",
465        Some("png") => "image/png",
466        Some("jpg" | "jpeg") => "image/jpeg",
467        Some("gif") => "image/gif",
468        Some("webp") => "image/webp",
469        Some("ico") => "image/x-icon",
470        Some("woff2") => "font/woff2",
471        Some("woff") => "font/woff",
472        Some("ttf") => "font/ttf",
473        Some("wasm") => "application/wasm",
474        Some("map") => "application/json; charset=utf-8",
475        _ => "application/octet-stream",
476    }
477}