iced_servo 0.1.0

Embed a Servo webview inside an Iced application via an offscreen rendering context
Documentation

iced_servo

Embed a Servo webview as a regular widget inside an Iced application.

Servo renders into an offscreen SoftwareRenderingContext; the resulting frame is read back via read_to_image and drawn through the generic iced_frame widget (a wgpu-textured quad) that sits in the widget tree like any other element. ServoWebViewController implements the FrameSource trait from iced_frame. Mouse, keyboard, touch, IME, and window-focus events flow the other way: iced's native event system delivers them to the widget, which translates and forwards them to Servo via WebView::notify_input_event. Cross-platform by construction — there are no platform-specific child windows, WndProcs, NSView subviews, or X11/Wayland subsurfaces to maintain.

Requirements

  • An iced application using the wgpu renderer (the default). The widget relies on iced_wgpu::primitive::Renderer to draw its textured quad, so iced_tiny_skia-only apps won't compile against this crate.
  • Only one ServoRuntime per process. Servo's startup options live in a process-wide singleton; constructing a second runtime panics at set_opts. Build one runtime at app startup and pass it into every controller.

Architecture

ServoRuntime       — one per process: owns Servo + SoftwareRenderingContext
   │
   ├── ServoWebViewController (tab 1) — one WebView, frame slot, delegate
   ├── ServoWebViewController (tab 2) — …
   └── ServoWebViewController (tab 3) — …

Each controller owns one servo::WebView. All controllers share the same runtime's rendering context, so only the active tab should be tick()-pumped on a given frame; calling activate() / deactivate() when switching tabs shows/hides the underlying webview so Servo's paint goes to the right pixels.

When page content calls window.open or clicks a target="_blank" link, Servo fires request_create_new. The crate satisfies the request with a throwaway popup whose only job is to capture the popup's first navigation URL and hand it to the embedder via the on_new_webview_requested callback. The app decides what to do with it — the basic example opens a new browser tab.

Usage

use iced_servo::{ServoRuntime, ServoWebViewController, WebViewConfig, frame};

// Once per app:
let runtime = ServoRuntime::new(dpi::PhysicalSize::new(1024, 768))?;

// Per tab / view:
let controller = ServoWebViewController::new(
    &runtime,
    WebViewConfig::default().url("https://servo.org"),
    1.0,
)?;
controller.activate();

controller.on_new_webview_requested(|url| {
    // Open a new tab, or whatever — the URL was captured from a
    // window.open / target="_blank" link.
});

// In view():
frame(&controller).width(Length::Fill).height(Length::Fill)

// In update(), drive the active tab's controller on every tick:
controller.tick();

See examples/browser.rs for a full tabbed browser with URL bar, back/forward/reload buttons, and per-tab session history. examples/basic.rs is the minimal "load a page in a window" version.

Host ↔ page interaction

Servo 0.1 exposes asynchronous JavaScript evaluation through the WebView API; iced_servo surfaces it both as a callback and as an iced::Task so it composes naturally with update():

use iced_servo::{JSValue, JavaScriptEvaluationError};

// Returns a Task<Result<JSValue, JavaScriptEvaluationError>>.
Message::ReadTitle => controller
    .evaluate_javascript_task("document.title")
    .map(Message::TitleLoaded),

Message::TitleLoaded(Ok(JSValue::String(title))) => {
    self.page_title = title;
    Task::none()
}

JSValue is a structured enum (Number, String, Array, Object, plus opaque DOM-handle variants), so nested data round-trips losslessly without a JSON intermediate. There is currently no Rust-side DOM AST — every host→page interaction is "compose a JS expression, eval, parse the returned JSValue". Use Servo's UserContentManager to inject document-start scripts if you need page→host messaging.

Build prerequisites

Servo is a heavy dependency. The first build downloads hundreds of crates and several GB; expect 30+ minutes. Each platform needs Servo's system toolchain prerequisites — see the official setup guide:

https://book.servo.org/hacking/building-servo.html

Summary for each platform:

Windows:

  • Visual Studio 2022 with Win10/11 SDK ≥ 10.0.19041, MSVC v143 build tools, C++ ATL
  • LLVM/Clang 19+ on PATH with LIBCLANG_PATH pointing at the bin dir containing libclang.dll
  • MozillaBuild (needed by mozjs_sys for SpiderMonkey)
  • Python 3

macOS:

  • Xcode Command Line Tools, Homebrew packages listed in the Servo book

Linux:

  • See the Servo book for distro-specific package lists (build-essential, cmake, libssl-dev, etc.)

Optional: enable the no-wgl feature on Windows to use ANGLE (D3D11) instead of WGL — matches what servoshell ships with:

iced_servo = { version = "0.1", features = ["no-wgl"] }

Examples

cargo run -p iced_servo --example basic

The smallest possible integration: one runtime, one controller, one frame widget. Loads https://servo.org and renders it. ~70 lines total.

cargo run -p iced_servo --example browser

A real tabbed browser built on the same crate: URL bar, back/forward/reload, close/new-tab, and target="_blank" links that open new tabs via on_new_webview_requested.

Performance notes

SoftwareRenderingContext renders on Microsoft's WARP software rasterizer on Windows and Mesa/EGL elsewhere; pages are laid out by Servo but every frame goes through CPU rasterization and a GPU→CPU→GPU round-trip per paint. Heavy, JS-driven sites (Google Search, Gmail) are noticeably slower than a GPU-backed browser. The OffscreenRenderingContext path (when available in Servo's public API) is the future upgrade target, with zero pixels crossing the CPU.