web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(wpe): tabs, with page state driven by signals rather than polling
The old sync_page_state read properties off the active webview, which is
fine for one tab and silently wrong for several: a tab loading in the
background stayed titleless and permanently "loading" until you switched
to it, and the tab strip shows a title per tab.
Each tab now owns an Rc<TabState> that WebKit's notify::title,
notify::uri and notify::is-loading handlers write into, and pump folds
every dirty tab's state into the fields the chrome reads. examples/wpe_tabs
opens a second tab and immediately backgrounds it, never activating it
again:
tab1: title=Some("Example Domain") url=https://example.org/ loading=false
Conclusive because title is seeded None and loading seeded true at
construction, so both could only have changed by a signal firing on a
webview that was never active.
The Rc is not incidental. A connected closure outlives any borrow we could
hand it, and tabs live in a Vec that reallocates, so the state cannot be
addressed through the Tab. Each connection owns a ref released by a
destroy-notify, and Tab::drop unrefs the webview *before* the state can go
— destroying the object is what runs those notifies, so the other order is
a use-after-free rather than a leak.
close_tab mirrors ServoHost's, including how the next active index is
chosen and the usize::MAX sentinel that stops activate short-circuiting.
Dropping the Tab is what unrefs the webview and frees its registry image;
before this, nothing ever unreffed a webview at all.
Verified through open, background, switch, close, and close-the-last with
no crash, and the earlier host and input examples still pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
examples/wpe_tabs.rs | 71 ++++++++++++++++++++++++++
src/wpe/host.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 193 insertions(+), 18 deletions(-)
diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
new file mode 100644
index 0000000..bb27746
--- /dev/null
+++ b/examples/wpe_tabs.rs
@@ -0,0 +1,71 @@
+//! Tabs: several views on one display, and — the point of the change —
+//! **background tabs updating their own state**.
+//!
+//! The old polling read only the active webview, so a tab loading in the
+//! background stayed titleless until you switched to it. Here tab 1 is opened
+//! and immediately backgrounded; if signals work it still reports its title.
+//!
+//! `cargo run --release -p cce-browser --features wpe --example wpe_tabs`
+
+#[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 mut host = wpe::WebKitHost::new(
+ url::Url::parse("https://example.com").unwrap(),
+ (1200, 800),
+ );
+ let settle = |h: &mut wpe::WebKitHost, n: u32| {
+ for _ in 0..n {
+ h.pump();
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+ };
+ let dump = |h: &wpe::WebKitHost, label: &str| {
+ println!("{label} (active={} of {})", h.active_index(), h.tab_count());
+ for i in 0..h.tab_count() {
+ let t = h.tab(i).unwrap();
+ println!(
+ " tab{i}: title={:?} url={:?} loading={}",
+ t.title,
+ t.url.as_ref().map(|u| u.as_str()),
+ t.loading
+ );
+ }
+ };
+
+ settle(&mut host, 30);
+ dump(&host, "-- one tab --");
+
+ println!("\n-- open tab 1, then immediately background it --");
+ host.open_tab(url::Url::parse("https://example.org").unwrap());
+ host.activate(0); // switch away before it can finish loading
+ settle(&mut host, 40);
+ dump(&host, " after settling (tab1 was never active again)");
+
+ let bg_ok = host.tab(1).map(|t| t.title.is_some() && !t.loading).unwrap_or(false);
+ println!("\n background tab reported state: {}", if bg_ok { "OK" } else { "MISSING" });
+
+ println!("\n-- switch to tab 1 --");
+ host.activate(1);
+ settle(&mut host, 10);
+ println!(" active title={:?} image={:?}", host.title(), host.image());
+
+ println!("\n-- close tab 1 --");
+ assert!(host.close_tab(1), "should not be the last tab");
+ settle(&mut host, 6);
+ dump(&host, " after close");
+
+ println!("\n-- close the last tab --");
+ let more = host.close_tab(0);
+ println!(" close_tab returned {more} (false = was the last)");
+ assert!(!more);
+ println!("\nOK — no crash through open/background/switch/close");
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 0d16e72..a629121 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -14,6 +14,7 @@
//! 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::cell::{Cell, RefCell};
use std::ffi::{c_char, CString};
use std::rc::Rc;
@@ -26,18 +27,86 @@ use super::glib_source::GlibPoll;
use super::input;
use super::subclass::{types, FRAME_SINK};
+/// Page state a tab's WebKit signals write into.
+///
+/// Held behind an `Rc` because each connected signal owns a reference: the
+/// closure outlives any borrow we could hand it, and the webview may emit
+/// after the `Tab` has moved within `tabs` (a `Vec` reallocates).
+#[derive(Default)]
+struct TabState {
+ title: RefCell<Option<String>>,
+ url: RefCell<Option<Url>>,
+ loading: Cell<bool>,
+ /// Set by any signal, cleared by `pump`. This is what lets a *background*
+ /// tab report a title change — the old polling only ever looked at the
+ /// active webview.
+ dirty: Cell<bool>,
+}
+
/// 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,
+ state: Rc<TabState>,
pub title: Option<String>,
pub url: Option<Url>,
pub loading: bool,
image: Option<(u32, u32, u32)>,
}
+impl Drop for Tab {
+ fn drop(&mut self) {
+ // Unref the webview *first*: destroying it runs the closures'
+ // destroy-notify, which releases their `Rc<TabState>` refs. Dropping
+ // the state before the object that can still emit into it would be a
+ // use-after-free.
+ unsafe { g_object_unref(self.webview as *mut _) };
+ if let Some((id, ..)) = self.image {
+ cce_ui::vk::free_image(id);
+ }
+ }
+}
+
+/// `notify::` handler shared by title / uri / is-loading: read the property
+/// straight back off the emitting webview and stash it.
+unsafe extern "C" fn on_notify(
+ obj: *mut GObject,
+ _pspec: *mut GParamSpec,
+ data: gpointer,
+) {
+ let st = &*(data as *const TabState);
+ let wv = obj as *mut WebKitWebView;
+ *st.title.borrow_mut() = from_cstr(webkit_web_view_get_title(wv));
+ if let Some(u) = from_cstr(webkit_web_view_get_uri(wv)).and_then(|u| Url::parse(&u).ok()) {
+ *st.url.borrow_mut() = Some(u);
+ }
+ st.loading.set(webkit_web_view_is_loading(wv) != 0);
+ st.dirty.set(true);
+}
+
+/// Releases the `Rc` ref a connection owned, when the closure is destroyed.
+unsafe extern "C" fn drop_state_ref(data: gpointer, _closure: *mut GClosure) {
+ drop(Rc::from_raw(data as *const TabState));
+}
+
+unsafe fn connect_notify(wv: *mut WebKitWebView, signal: &str, state: &Rc<TabState>) {
+ let name = cstr(signal);
+ // Each connection owns its own ref, handed back by `drop_state_ref`.
+ let raw = Rc::into_raw(state.clone()) as gpointer;
+ g_signal_connect_data(
+ wv as *mut _,
+ name.as_ptr(),
+ Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
+ on_notify as unsafe extern "C" fn(*mut GObject, *mut GParamSpec, gpointer),
+ )),
+ raw,
+ Some(drop_state_ref),
+ 0,
+ );
+}
+
/// 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.
@@ -107,7 +176,7 @@ impl WebKitHost {
}
}
- fn build_webview(&self, url: &Url) -> (*mut WebKitWebView, *mut WPEView) {
+ fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
unsafe {
let prop = cstr("display");
let wv = g_object_new(
@@ -118,6 +187,11 @@ impl WebKitHost {
) as *mut WebKitWebView;
let view = webkit_web_view_get_wpe_view(wv);
wpe_view_set_toplevel(view, self.toplevel);
+ // Signals, not polling: a background tab has to be able to report
+ // its title without anyone asking the active webview.
+ for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
+ connect_notify(wv, sig, state);
+ }
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);
@@ -128,10 +202,14 @@ impl WebKitHost {
}
pub fn open_tab(&mut self, url: Url) {
- let (webview, view) = self.build_webview(&url);
+ let state = Rc::new(TabState::default());
+ state.loading.set(true);
+ *state.url.borrow_mut() = Some(url.clone());
+ let (webview, view) = self.build_webview(&url, &state);
self.tabs.push(Tab {
webview,
view,
+ state,
title: None,
url: Some(url),
loading: true,
@@ -140,6 +218,32 @@ impl WebKitHost {
self.activate(self.tabs.len() - 1);
}
+ /// Close a tab. Returns false when that was the last one (the app should
+ /// exit; the tab is gone either way). Mirrors `ServoHost::close_tab`,
+ /// including how the next active index is chosen.
+ pub fn close_tab(&mut self, index: usize) -> bool {
+ if index >= self.tabs.len() {
+ return true;
+ }
+ let was_active = index == self.active;
+ let old_active = self.active;
+ // Dropping the Tab unrefs the webview and frees its registry image.
+ drop(self.tabs.remove(index));
+ if self.tabs.is_empty() {
+ return false;
+ }
+ let next = if was_active {
+ index.min(self.tabs.len() - 1)
+ } else if old_active > index {
+ old_active - 1
+ } else {
+ old_active
+ };
+ self.active = usize::MAX; // force activate() to do the work
+ self.activate(next);
+ true
+ }
+
/// 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) {
@@ -218,25 +322,25 @@ impl WebKitHost {
(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.
+ /// Fold each tab's signal-written state into the fields the chrome reads.
+ ///
+ /// Every tab, not just the active one — that is the whole point of moving
+ /// off polling. The tab strip shows a title per tab, so a background tab
+ /// finishing a load has to be visible without switching to it.
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;
+ let mut changed = false;
+ for tab in &mut self.tabs {
+ if !tab.state.dirty.replace(false) {
+ continue;
+ }
+ tab.title = tab.state.title.borrow().clone();
+ if let Some(u) = tab.state.url.borrow().clone() {
+ tab.url = Some(u);
}
- tab.loading = loading;
- changed
+ tab.loading = tab.state.loading.get();
+ changed = true;
}
+ changed
}
pub fn image(&self) -> Option<(u32, u32, u32)> {