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}