git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit3aca0289b9c1c09c745ad47f11623feed7dcf1ff
parent205d5e5d21
authorLucas Galante <[email protected]>
date2026-08-28 08:25
feat(wpe): WebKitHost — the ServoHost-shaped API over WPE

Boots, pumps, renders, navigates. Exercised by examples/wpe_host.rs
against the real API rather than the raw FFI:

  frame#1 image=Some((1, 1200, 800))
  title=Some("Example Domain") url=https://example.com/ loading=false
  -- navigating to example.org --   back=true
  -- back --                        url=https://example.com/ fwd=true

So construction, the frame path into cce-ui's image registry, page state,
and history all work. Still under --features wpe, still not consumed by
main.rs; the shipping browser remains Servo and the default build is
unchanged (verified both ways).

The method surface deliberately mirrors ServoHost so main.rs can switch by
changing a type name rather than its logic — same usize::MAX activate
sentinel, same (new_frame, dirty) pump return, same (id, w, h) image
tuple, same free_image-on-replace discipline.

Loop integration is the one genuine difference and the one thing here that
is knowingly temporary. Servo pushed Message::Spin into calloop from its
own threads; WPE runs a GLib main context, so pump drains it non-blocking
and something has to call pump. Today that is polling. The right answer is
to put the context's pollfds into calloop so the app wakes only when GLib
has work, and this milestone deliberately does not attempt it — the point
was the engine, not the event loop.

Frames are held in a slot rather than a queue, and only the newest is
uploaded. That is not caution about the 54 GB incident so much as a
description of the protocol: WPE will not produce another buffer until the
current one is released, so there is nothing to accumulate.

Page state is polled off webview properties in sync_page_state. That is
fine for one tab and wrong for several — background tabs will not update.
Signals (notify::title, load-changed) are the answer when tabs land.

Co-Authored-By: Claude Opus 5 <[email protected]>

 build.rs             |   4 +-
 examples/wpe_host.rs |  63 +++++++++++
 src/main.rs          |   4 +
 src/wpe/host.rs      | 290 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/wpe/mod.rs       |  17 +++
 src/wpe/subclass.rs  | 170 ++++++++++++++++++++++++++++++
 6 files changed, 547 insertions(+), 1 deletion(-)

diff --git a/build.rs b/build.rs
index ff1b10b..c9bcdbc 100644
--- a/build.rs
+++ b/build.rs
@@ -29,7 +29,9 @@ fn main() {
         // The engine, the embedding layer, and just enough GObject to
         // register subclasses and turn a main loop.
         .allowlist_item("(wpe|WPE|webkit|WebKit)_?.*")
-        .allowlist_item("g_(object|type|signal|main_loop|bytes|timeout|free|error)_.*")
+        .allowlist_item("g_(object|type|signal|bytes|timeout|free|error)_.*")
+        // The main loop AND the context: `pump` drains the context directly.
+        .allowlist_item("g_main_(loop|context)_.*")
         .allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
         .allowlist_item("g_(type|object)_.*")
         // GObject's generated enums are plain C enums; keep them as consts so
diff --git a/examples/wpe_host.rs b/examples/wpe_host.rs
new file mode 100644
index 0000000..1daaa21
--- /dev/null
+++ b/examples/wpe_host.rs
@@ -0,0 +1,63 @@
+//! Exercises `WebKitHost` — the real API surface `main.rs` will consume,
+//! not the raw FFI. Proves boot → pump → frames → page state.
+//!
+//! `cargo run --release -p cce-browser --features wpe --example wpe_host`
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+    eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+    let url = std::env::args()
+        .nth(1)
+        .unwrap_or_else(|| "https://example.com".into());
+    let mut host = wpe::WebKitHost::new(url::Url::parse(&url).unwrap(), (1200, 800));
+
+    let mut frames = 0;
+    let mut navigated = false;
+    for i in 0..60 {
+        // Once the first page settles, navigate and come back — exercises
+        // load / can_go_back / back on a live history.
+        if !navigated && !host.loading() && frames > 1 && i > 10 {
+            navigated = true;
+            println!("-- navigating to example.org --");
+            host.load(url::Url::parse("https://example.org").unwrap());
+        }
+        if navigated && i == 40 {
+            println!("-- back (can_go_back={}) --", host.can_go_back());
+            host.back();
+        }
+        let (new_frame, dirty) = host.pump();
+        if new_frame {
+            frames += 1;
+            println!(
+                "t={:>4}ms frame#{frames} image={:?}",
+                i * 100,
+                host.image()
+            );
+        }
+        if dirty {
+            println!(
+                "  state: title={:?} url={:?} loading={} back={} fwd={}",
+                host.title(),
+                host.url().map(|u| u.to_string()),
+                host.loading(),
+                host.can_go_back(),
+                host.can_go_forward()
+            );
+        }
+        std::thread::sleep(std::time::Duration::from_millis(100));
+    }
+    println!(
+        "done: {frames} frames, tabs={} active={}",
+        host.tab_count(),
+        host.active_index()
+    );
+    assert!(frames > 0, "no frames produced");
+}
diff --git a/src/main.rs b/src/main.rs
index 400a10a..3dbd510 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,10 @@ mod downloads;
 mod pages;
 mod settings;
 mod webview;
+/// The in-progress WPE WebKit backend (see WPE-PORT.md). Compiled only under
+/// `--features wpe`; the shipping browser is still Servo.
+#[cfg(feature = "wpe")]
+mod wpe;
 
 use url::Url;
 use wayland_client::QueueHandle;
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
new file mode 100644
index 0000000..b90813d
--- /dev/null
+++ b/src/wpe/host.rs
@@ -0,0 +1,290 @@
+//! `WebKitHost` — the WPE-backed twin of `webview.rs`'s `ServoHost`.
+//!
+//! Deliberately mirrors that type's method surface so `main.rs` can switch
+//! engines by changing a type name rather than its logic. Frames land in
+//! cce-ui's image registry exactly as before, so `display_list` is unchanged:
+//! the page is still one full-bleed quad.
+//!
+//! **Loop integration is the one real difference.** Servo had an
+//! `EventLoopWaker` that pushed `Message::Spin` into calloop from its own
+//! threads; WPE runs on a GLib `GMainContext`. [`WebKitHost::pump`] therefore
+//! drains that context non-blockingly, which keeps the same shape as
+//! `ServoHost::pump` but means *something has to call it*. Today that is the
+//! app's `tick`. The correct fix is to put the context's pollfds into calloop
+//! so the app wakes only when GLib has work — see WPE-PORT.md; doing it by
+//! polling first keeps this milestone about the engine, not the event loop.
+
+use std::ffi::{c_char, CString};
+use std::rc::Rc;
+
+use url::Url;
+
+use super::ffi::*;
+use super::subclass::{types, FRAME_SINK};
+
+/// One tab: its webview plus the app-visible page state and the last frame
+/// uploaded to the image registry (id, w px, h px). Same shape as
+/// `webview::Tab` so the chrome reads it identically.
+pub struct Tab {
+    webview: *mut WebKitWebView,
+    view: *mut WPEView,
+    pub title: Option<String>,
+    pub url: Option<Url>,
+    pub loading: bool,
+    image: Option<(u32, u32, u32)>,
+}
+
+/// Frames handed over by `render_buffer`, drained by `pump`. A slot, not a
+/// queue: only the newest frame is worth uploading, and WPE will not produce
+/// another until we release the current one anyway.
+#[derive(Default)]
+struct Pending {
+    frame: Option<(Vec<u8>, u32, u32)>,
+}
+
+pub struct WebKitHost {
+    display: *mut WPEDisplay,
+    toplevel: *mut WPEToplevel,
+    tabs: Vec<Tab>,
+    active: usize,
+    size_px: (u32, u32),
+    scale: f32,
+    pending: Rc<std::cell::RefCell<Pending>>,
+}
+
+unsafe fn cstr(s: &str) -> CString {
+    CString::new(s).expect("no interior nul")
+}
+
+impl WebKitHost {
+    /// Boot WPE and open the first tab.
+    ///
+    /// One host per process: the frame sink and the GType registrations are
+    /// process-wide. That matches the app (one browser window per process)
+    /// but is worth knowing before writing a test that builds two.
+    pub fn new(url: Url, size_px: (u32, u32)) -> Self {
+        unsafe {
+            let t = types();
+            let display = g_object_new(t.display, std::ptr::null::<c_char>()) as *mut WPEDisplay;
+            let mut err: *mut GError = std::ptr::null_mut();
+            assert!(
+                wpe_display_connect(display, &mut err) != 0,
+                "wpe_display_connect failed"
+            );
+
+            let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
+            let sink = pending.clone();
+            FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
+                if let Some(f) = read_shm(buffer) {
+                    // Replace, never accumulate: the newest frame wins.
+                    sink.borrow_mut().frame = Some(f);
+                }
+            }));
+
+            let toplevel = wpe_display_create_toplevel(display, 1);
+            wpe_toplevel_resized(toplevel, size_px.0 as i32, size_px.1 as i32);
+
+            let mut host = Self {
+                display,
+                toplevel,
+                tabs: Vec::new(),
+                active: usize::MAX, // sentinel: force activate() to do the work
+                size_px,
+                scale: 1.0,
+                pending,
+            };
+            host.open_tab(url);
+            host
+        }
+    }
+
+    fn build_webview(&self, url: &Url) -> (*mut WebKitWebView, *mut WPEView) {
+        unsafe {
+            let prop = cstr("display");
+            let wv = g_object_new(
+                webkit_web_view_get_type(),
+                prop.as_ptr(),
+                self.display,
+                std::ptr::null::<c_char>(),
+            ) as *mut WebKitWebView;
+            let view = webkit_web_view_get_wpe_view(wv);
+            wpe_view_set_toplevel(view, self.toplevel);
+            wpe_view_resized(view, self.size_px.0 as i32, self.size_px.1 as i32);
+            wpe_view_set_visible(view, 1);
+            wpe_view_map(view);
+            let curl = cstr(url.as_str());
+            webkit_web_view_load_uri(wv, curl.as_ptr());
+            (wv, view)
+        }
+    }
+
+    pub fn open_tab(&mut self, url: Url) {
+        let (webview, view) = self.build_webview(&url);
+        self.tabs.push(Tab {
+            webview,
+            view,
+            title: None,
+            url: Some(url),
+            loading: true,
+            image: None,
+        });
+        self.activate(self.tabs.len() - 1);
+    }
+
+    /// Make tab `index` visible and focused. Mirrors `ServoHost::activate`,
+    /// including the `usize::MAX` sentinel so the first call is not a no-op.
+    pub fn activate(&mut self, index: usize) {
+        if index >= self.tabs.len() || index == self.active {
+            return;
+        }
+        unsafe {
+            if let Some(old) = self.tabs.get(self.active) {
+                wpe_view_unmap(old.view);
+                wpe_view_set_visible(old.view, 0);
+            }
+            self.active = index;
+            let tab = &self.tabs[index];
+            wpe_view_set_toplevel(tab.view, self.toplevel);
+            wpe_view_set_visible(tab.view, 1);
+            wpe_view_map(tab.view);
+            wpe_view_resized(tab.view, self.size_px.0 as i32, self.size_px.1 as i32);
+        }
+    }
+
+    pub fn tab_count(&self) -> usize {
+        self.tabs.len()
+    }
+    pub fn active_index(&self) -> usize {
+        self.active
+    }
+    pub fn tab(&self, index: usize) -> Option<&Tab> {
+        self.tabs.get(index)
+    }
+    fn active_tab(&self) -> &Tab {
+        &self.tabs[self.active]
+    }
+
+    /// Drain GLib's pending work, then upload any frame it produced.
+    /// Returns (new frame, any state change) like `ServoHost::pump`.
+    pub fn pump(&mut self) -> (bool, bool) {
+        unsafe {
+            while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
+        }
+        let frame = self.pending.borrow_mut().frame.take();
+        let dirty = self.sync_page_state();
+        let Some((px, w, h)) = frame else {
+            return (false, dirty);
+        };
+        let id = cce_ui::vk::upload_rgba(px, w, h);
+        let tab = &mut self.tabs[self.active];
+        if let Some((old, ..)) = tab.image.replace((id, w, h)) {
+            cce_ui::vk::free_image(old);
+        }
+        (true, true)
+    }
+
+    /// Pull title/url/loading off the active webview. WebKit exposes these as
+    /// properties; polling them here keeps the delegate-free shape of this
+    /// first cut. Signals (`notify::title`, `load-changed`) are the better
+    /// answer once tabs land, so background tabs update too.
+    fn sync_page_state(&mut self) -> bool {
+        unsafe {
+            let tab = &mut self.tabs[self.active];
+            let title = from_cstr(webkit_web_view_get_title(tab.webview));
+            let uri = from_cstr(webkit_web_view_get_uri(tab.webview));
+            let loading = webkit_web_view_is_loading(tab.webview) != 0;
+            let url = uri.and_then(|u| Url::parse(&u).ok());
+            let changed = title != tab.title || url != tab.url || loading != tab.loading;
+            tab.title = title;
+            if url.is_some() {
+                tab.url = url;
+            }
+            tab.loading = loading;
+            changed
+        }
+    }
+
+    pub fn image(&self) -> Option<(u32, u32, u32)> {
+        self.active_tab().image
+    }
+    pub fn title(&self) -> Option<String> {
+        self.active_tab().title.clone()
+    }
+    pub fn url(&self) -> Option<Url> {
+        self.active_tab().url.clone()
+    }
+    pub fn loading(&self) -> bool {
+        self.active_tab().loading
+    }
+
+    pub fn load(&self, url: Url) {
+        unsafe {
+            let c = cstr(url.as_str());
+            webkit_web_view_load_uri(self.active_tab().webview, c.as_ptr());
+        }
+    }
+    pub fn reload(&self) {
+        unsafe { webkit_web_view_reload(self.active_tab().webview) }
+    }
+    pub fn back(&self) {
+        unsafe { webkit_web_view_go_back(self.active_tab().webview) }
+    }
+    pub fn forward(&self) {
+        unsafe { webkit_web_view_go_forward(self.active_tab().webview) }
+    }
+    pub fn can_go_back(&self) -> bool {
+        unsafe { webkit_web_view_can_go_back(self.active_tab().webview) != 0 }
+    }
+    pub fn can_go_forward(&self) -> bool {
+        unsafe { webkit_web_view_can_go_forward(self.active_tab().webview) != 0 }
+    }
+
+    pub fn resize(&mut self, width_px: u32, height_px: u32, scale: f32) {
+        self.size_px = (width_px.max(1), height_px.max(1));
+        self.scale = scale;
+        unsafe {
+            wpe_toplevel_resized(self.toplevel, self.size_px.0 as i32, self.size_px.1 as i32);
+            let view = self.active_tab().view;
+            wpe_view_resized(view, self.size_px.0 as i32, self.size_px.1 as i32);
+        }
+    }
+}
+
+/// Copy an SHM buffer's pixels out as RGBA for `upload_rgba`.
+///
+/// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, and the
+/// stride is not assumed to equal `width * 4`.
+unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
+    if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
+        return None;
+    }
+    let shm = buffer as *mut WPEBufferSHM;
+    let (w, h) = (
+        wpe_buffer_get_width(buffer) as u32,
+        wpe_buffer_get_height(buffer) as u32,
+    );
+    let mut len: u64 = 0;
+    let src = g_bytes_get_data(wpe_buffer_shm_get_data(shm), &mut len as *mut u64) as *const u8;
+    if src.is_null() || w == 0 || h == 0 {
+        return None;
+    }
+    let stride = wpe_buffer_shm_get_stride(shm) as usize;
+    let mut out = vec![0u8; (w * h * 4) as usize];
+    for y in 0..h as usize {
+        for x in 0..w as usize {
+            let s = src.add(y * stride + x * 4);
+            let d = (y * w as usize + x) * 4;
+            out[d] = *s.add(2);
+            out[d + 1] = *s.add(1);
+            out[d + 2] = *s;
+            out[d + 3] = *s.add(3);
+        }
+    }
+    Some((out, w, h))
+}
+
+unsafe fn from_cstr(p: *const c_char) -> Option<String> {
+    (!p.is_null())
+        .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
+        .filter(|s| !s.is_empty())
+}
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
new file mode 100644
index 0000000..51f9d0a
--- /dev/null
+++ b/src/wpe/mod.rs
@@ -0,0 +1,17 @@
+//! The WPE WebKit engine backend (feature `wpe`, off by default).
+//!
+//! Mirrors `webview.rs`'s `ServoHost` surface so `main.rs` can swap engines
+//! with minimal churn — see WPE-PORT.md. Nothing here is wired into the app
+//! yet; the shipping browser is still Servo.
+
+pub mod ffi {
+    #![allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)]
+    include!(concat!(env!("OUT_DIR"), "/wpe_bindings.rs"));
+}
+
+mod subclass;
+mod host;
+
+// Not consumed yet — main.rs still drives ServoHost.
+#[allow(unused_imports)]
+pub use host::{Tab, WebKitHost};
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
new file mode 100644
index 0000000..9cd0e65
--- /dev/null
+++ b/src/wpe/subclass.rs
@@ -0,0 +1,170 @@
+//! The three GObject subclasses WPE requires of an embedder.
+//!
+//! WebKit does not hand us a view to render into; it *asks the display for
+//! one*. So embedding means implementing all three of:
+//!
+//! * `WPEDisplay`  — vends the view and the toplevel (`create_view`,
+//!   `create_toplevel`). `WebKitWebView`'s `display` property is
+//!   construct-only and takes this.
+//! * `WPEToplevel` — **owns buffer-format negotiation.** WebKit asks the
+//!   toplevel, not the display. Leave `create_toplevel` NULL and
+//!   `render_buffer` silently never fires, with a perfectly healthy web
+//!   process and no error anywhere.
+//! * `WPEView`     — receives finished frames via `render_buffer`.
+//!
+//! Registration goes through [`register_subclass`] rather than a Rust struct
+//! embedding the parent, because WPE's instance structs are opaque
+//! (`WPE_DECLARE_DERIVABLE_TYPE` typedefs `struct _WPEView` and never defines
+//! it). `g_type_query` reports the parent's sizes at runtime instead, which is
+//! ABI-safe and survives WPE growing a field. The *class* structs are public,
+//! so bindgen lays them out correctly and installing a vfunc is a field set.
+
+use std::ffi::{c_char, c_void, CString};
+
+use super::ffi::*;
+
+/// Register a GObject subclass of `parent`, sized from the runtime type query.
+pub(super) unsafe fn register_subclass(
+    parent: GType,
+    name: &str,
+    class_init: unsafe extern "C" fn(*mut c_void, *mut c_void),
+) -> GType {
+    let mut q: GTypeQuery = std::mem::zeroed();
+    g_type_query(parent, &mut q);
+    assert!(q.type_ != 0, "parent type {name} not registered");
+    let cname = CString::new(name).expect("subclass name");
+    g_type_register_static_simple(
+        parent,
+        cname.as_ptr(),
+        q.class_size,
+        std::mem::transmute::<_, GClassInitFunc>(class_init),
+        q.instance_size,
+        None,
+        0,
+    )
+}
+
+pub(super) const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
+    (a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
+}
+
+/// Registered once, on first host construction. GType registration is
+/// process-wide and re-registering the same name aborts.
+pub(super) struct Types {
+    pub display: GType,
+    pub view: GType,
+    pub toplevel: GType,
+}
+
+static mut TYPES: Option<Types> = None;
+
+pub(super) unsafe fn types() -> &'static Types {
+    #[allow(static_mut_refs)]
+    if TYPES.is_none() {
+        TYPES = Some(Types {
+            view: register_subclass(wpe_view_get_type(), "CceWpeView", view_class_init),
+            toplevel: register_subclass(
+                wpe_toplevel_get_type(),
+                "CceWpeToplevel",
+                toplevel_class_init,
+            ),
+            display: register_subclass(wpe_display_get_type(), "CceWpeDisplay", display_class_init),
+        });
+    }
+    #[allow(static_mut_refs)]
+    TYPES.as_ref().unwrap()
+}
+
+// ---- view ----
+
+/// Set by the host before it creates a webview; `render_buffer` hands frames
+/// here. One host per process for now (see `WebKitHost::new`).
+pub(super) static mut FRAME_SINK: Option<Box<dyn FnMut(*mut WPEBuffer)>> = None;
+
+unsafe extern "C" fn view_render_buffer(
+    view: *mut WPEView,
+    buffer: *mut WPEBuffer,
+    _damage: *const WPERectangle,
+    _n_damage: u32,
+    _error: *mut *mut GError,
+) -> gboolean {
+    #[allow(static_mut_refs)]
+    if let Some(sink) = FRAME_SINK.as_mut() {
+        sink(buffer);
+    }
+    // BOTH halves. `rendered` means displayed, `released` means the memory is
+    // yours again; with only the first the engine produces exactly one frame
+    // and then stalls forever. This is also the backpressure that makes an
+    // unbounded upload queue impossible here.
+    wpe_view_buffer_rendered(view, buffer);
+    wpe_view_buffer_released(view, buffer);
+    1
+}
+
+unsafe extern "C" fn view_class_init(class: *mut c_void, _data: *mut c_void) {
+    (*(class as *mut WPEViewClass)).render_buffer = Some(view_render_buffer);
+}
+
+// ---- toplevel ----
+
+unsafe extern "C" fn toplevel_formats(_t: *mut WPEToplevel) -> *mut WPEBufferFormats {
+    // Mappable ARGB/XRGB linear: what we can read back on the CPU and hand
+    // straight to `cce_ui::vk::upload_rgba`. DMABuf comes later (phase 2).
+    let b = wpe_buffer_formats_builder_new(std::ptr::null_mut());
+    wpe_buffer_formats_builder_append_group(
+        b,
+        std::ptr::null_mut(),
+        WPEBufferFormatUsage::WPE_BUFFER_FORMAT_USAGE_MAPPING,
+    );
+    for cc in [fourcc(b'A', b'R', b'2', b'4'), fourcc(b'X', b'R', b'2', b'4')] {
+        wpe_buffer_formats_builder_append_format(b, cc, 0);
+    }
+    wpe_buffer_formats_builder_end(b)
+}
+
+unsafe extern "C" fn toplevel_resize(t: *mut WPEToplevel, w: i32, h: i32) -> gboolean {
+    wpe_toplevel_resized(t, w, h);
+    1
+}
+
+unsafe extern "C" fn toplevel_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEToplevelClass;
+    (*c).get_preferred_buffer_formats = Some(toplevel_formats);
+    (*c).resize = Some(toplevel_resize);
+}
+
+// ---- display ----
+
+unsafe extern "C" fn display_connect(_d: *mut WPEDisplay, _e: *mut *mut GError) -> gboolean {
+    1
+}
+
+unsafe extern "C" fn display_create_view(d: *mut WPEDisplay) -> *mut WPEView {
+    let prop = CString::new("display").unwrap();
+    g_object_new(types().view, prop.as_ptr(), d, std::ptr::null::<c_char>()) as *mut WPEView
+}
+
+unsafe extern "C" fn display_create_toplevel(
+    d: *mut WPEDisplay,
+    max_views: u32,
+) -> *mut WPEToplevel {
+    let (p1, p2) = (
+        CString::new("display").unwrap(),
+        CString::new("max-views").unwrap(),
+    );
+    g_object_new(
+        types().toplevel,
+        p1.as_ptr(),
+        d,
+        p2.as_ptr(),
+        max_views,
+        std::ptr::null::<c_char>(),
+    ) as *mut WPEToplevel
+}
+
+unsafe extern "C" fn display_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEDisplayClass;
+    (*c).connect = Some(display_connect);
+    (*c).create_view = Some(display_create_view);
+    (*c).create_toplevel = Some(display_create_toplevel);
+}