Webview
Show web pages in a window with WebView and webview(): the app-owned handle, commands, events, WKWebView and CEF, and what Linux and Windows do not have yet.
A page is a WebView handle that your app owns. The webview(id, &page) element only shows it. That split is the whole design: switching tabs or moving the element to another window never reloads the page, because the element is not what holds it.
On macOS this is shipped with WKWebView and, as an opt-in, Chromium through CEF. On Linux and Windows the engines are not written yet, see what is missing.
The handle and the element
Create a page, keep it in your state, and mount it:
fn options() -> WebViewOptions {
WebViewOptions::default().adopt_popups().inspectable(true)
}
From examples/browser/src/lib.rs, line 46.
WebView::with_options(options()) makes the handle and page.load(url) starts it. WebView::new() and WebView::with_url(url) are the short forms. The options are fixed at creation:
| Option | Effect |
|---|---|
.profile(..) | Default, Private (memory only, gone with the last page) or Named(name) (its own persistent store) |
.engine(..) | System or Cef for this page |
.adopt_popups() | window.open hands you a live page that keeps window.opener, not only a URL |
.inspectable(..) | allows Web Inspector or DevTools, on by default in debug builds |
.user_agent(..) | the user agent string |
The element goes in your render, keyed by an id of your choosing, with a handler for each event you care about:
webview(SharedString::from(format!("page-{id}")), &tab.page)
.size_full()
.context_menu_items(vec![
MenuItem::new("open-link-tab", "Open Link in New Tab").contexts(Contexts::LINK),
MenuItem::new("copy-address", "Copy Page Address"),
])
.on_navigate(cx.listener(move |this, e: &Navigate, _, cx| {
if this.selected == id {
this.address = e.url.clone().into();
}
update(cx, this, &|t| t.url = e.url.clone().into());
}))
.on_title_changed(cx.listener(move |this, e: &TitleChanged, _, cx| {
let title = if e.title.is_empty() { "Untitled".to_owned() } else { e.title.clone() };
update(cx, this, &|t| t.title = title.clone().into());
}))
From examples/browser/src/lib.rs, line 113.
A page can be mounted by one element at a time, the last one. Unmounting parks it: it keeps its state and keeps loading. It closes when the last clone of the handle drops. Clones share the page and compare with ==.
Commands
Commands are methods on the handle, and they are safe to call before the page has a view. They queue and replay in order once it does. The browser example maps its toolbar actions onto them:
.on_action(cx.listener(|this, _: &Reload, _, _| {
if let Some(page) = this.current() {
if page.is_loading() { page.stop() } else { page.reload() }
}
}))
.on_action(cx.listener(|this, _: &Back, _, _| {
if let Some(page) = this.current() {
page.go_back();
}
}))
.on_action(cx.listener(|this, _: &Forward, _, _| {
if let Some(page) = this.current() {
page.go_forward();
}
}))
.on_action(cx.listener(|this, _: &DevTools, _, _| {
if let Some(page) = this.current() {
page.open_dev_tools();
}
}))
From examples/browser/src/lib.rs, line 233.
The set: load, load_html, go_back, go_forward, reload, stop, set_zoom, set_user_agent, open_dev_tools, find with find_next, find_previous and stop_find, set_muted, focus, request_close, start_download, save_session and restore_session, the cookie calls, and the user script and script message calls below. State reads are immediate: url(), title(), is_loading(), can_go_back(), can_go_forward().
Anything that has an answer takes a callback instead of returning. nd has no executor, so the answer arrives as an FnOnce(T, &mut App), as with the dialogs:
page.execute_javascript("document.title", |title, cx| { /* Result<JsValue, JsError> */ });
A thrown exception is an Err with its message and a returned promise is awaited. get_cookies and save_session answer the same way. execute_javascript_in runs in a named isolated world.
Events
Handlers are on_navigate, on_title_changed, on_loading_changed, on_load_progress, on_load_failed, on_new_window, on_window_closed, on_download_requested, on_download_updated, on_context_menu_item_clicked, on_script_message and the rest of those in events.rs. Each delivers on the UI thread, never inside an engine callback.
Popups and tabs
With adopt_popups, NewWindow carries popup: Some(page): a page the engine already started, with window.opener intact. Add it as a tab and mount it. Without a popup you get the URL and the Disposition, such as BackgroundTab, and open a page yourself.
.on_new_window(cx.listener(move |this, e: &NewWindow, _, cx| {
let select = e.disposition != nd::webview::Disposition::BackgroundTab;
match &e.popup {
Some(popup) => {
this.push(popup.clone(), select);
}
None => this.open_tab(&e.url, select),
}
cx.notify();
}))
.on_window_closed(cx.listener(move |this, _: &WindowClosed, _, cx| {
this.close(id);
cx.notify();
}))
From examples/browser/src/lib.rs, line 141.
Downloads
A response the engine does not render, or an attachment, arrives as a Download that waits for you. Choose where it goes with save_to(path) or save_to_downloads(), or cancel() it. on_download_updated then reports Running, Done, Failed or Cancelled with the byte counts.
.on_download_requested(cx.listener(|this, e: &DownloadRequested, _, cx| {
let path = e.download.save_to_downloads();
this.status = format!("Downloading {}", path.display()).into();
cx.notify();
}))
.on_download_updated(cx.listener(|this, e: &DownloadUpdated, _, cx| {
this.status = match e.state {
DownloadState::Running => format!("Downloaded {} KB", e.received / 1024),
DownloadState::Done => {
format!("Saved {}", e.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default())
}
DownloadState::Failed => format!("Download failed: {}", e.error.clone().unwrap_or_default()),
DownloadState::Cancelled => "Download cancelled".into(),
}
.into();
cx.notify();
}))
From examples/browser/src/lib.rs, line 157.
Requests the app can leave unanswered
Permission prompts (camera, microphone, geolocation, notifications, clipboard), custom scheme requests and downloads each wait for an answer through a responder on the event. If the app drops the event without answering, the engine falls back instead of leaving the page waiting: WebKit shows its own permission prompt, a scheme request fails, a download is cancelled. A page never hangs on an app that registered no handler.
To answer a permission request, handle on_permission_request and call respond(decision) on its responder. A custom scheme is registered with nd::webview::register_scheme("app") before the first page is created and served through on_scheme_request.
Scripts and messages
add_user_script(UserScript::new(id, source)) injects a script into every page load, optionally in an isolated world. register_script_message("name", None) lets the page call window.webkit.messageHandlers.name.postMessage(value) on both engines, and on_script_message receives the ScriptMessage { name, body, .. } as JSON.
Context menus
By default the page shows the engine's own context menu. context_menu_items(vec![..]) merges your items into it in place, filtered by Contexts (link, image and so on), and a chosen one arrives as ContextMenuItemClicked with the page and link URLs. context_menu_mode can suppress the engine's menu or leave it native. The browser example adds "Open Link in New Tab" this way.
Engines
| System | CEF | |
|---|---|---|
| macOS | WKWebView, an NSView beside the window's other views | Chromium rendering off-screen, composited into the window |
| Windows | not written yet | not packaged yet |
| Linux | not written yet | not packaged yet |
| Web | not available | not available |
The default engine is [webview] engine in nativedesktop.toml: "system" or "cef". The choice is baked at build time. Override it per page with WebViewOptions::engine, and for a whole build and run with ND_WEBVIEW_ENGINE=cef, which nd package also honours. engine = "cef" needs nd's cef feature; without it the app carries no Chromium bytes, and packaging bundles Chromium only when the setting asks for it. The browser example sets engine = "system" in its nativedesktop.toml and builds with CEF through the environment variable.
[webview.cef] style = "chrome", a windowed Chrome-style mode for extensions, is accepted by the config but not implemented. Only the off-screen style runs.
WKWebView's inspector opens through the private _inspector SPI, as wry does. Without it the page stays inspectable from Safari.
Overlays over a page
A page is a native view, so a drawn element would normally sit under it. Popovers, menus and panels are above web views and never pass input through to them. See overlays. native_surface(..).count_input() in the overlays example counts what a page received, which is how the leak gate proves it.
Linux and Windows
On both, webview() renders a notice with the reason, and every command fails with it. The planned engines are WebView2 in composition mode on Windows and WPE WebKit on Linux, with CEF as the fallback if WPE does not embed. Nothing of the engine matrix runs there. Write the app against the handle as on macOS and it works once the engines land; until then, test the web parts on macOS.