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

commit5f83f41718042e80eee26c4eb6d3a6bf1f46c83e
parent84de5de5b8
authorLucas Galante <[email protected]>
date2026-08-28 10:48
feat(wpe): close the last seven API gaps against ServoHost

WebKitHost now presents the same surface main.rs already consumes, so the
swap becomes a type alias rather than a rewrite. Four of the seven are app
state rather than engine state and cross unchanged: active_bookmarked,
toggle_bookmark, set_history_enabled, and take_download_started, the last
an honest stub until downloads move to WebKit's own API.

The three that are engine work all came out smaller than their Servo
counterparts:

set_force_dark installs a user stylesheet and reloads once. The Servo path
needed two timed deadlines, at 400ms and 2.5s, because user content
reached the script thread as a separate message and force-dark also
flipped the reported scheme — either could land after a too-eager reload
and leave the page inverted the wrong way. WebKit applies user content to
live pages, so there is nothing to race. Verified by sampling the rendered
frame rather than trusting the call: a hardcoded-white page goes
(255,255,255) to (0,0,0), luminance 765 to 0.

set_color_scheme is one WPE setting, /wpe-platform/dark-mode, instead of
per-webview theme notifications that later tabs had to be told about.

editing_action is a named WebKit editing command. Servo needed an entire
CceClipboard delegate because its own arboard-backed one landed nothing in
that embedding; here the platform clipboard is simply used.

Examples now declare pages and downloads via #[path] alongside the wpe
module. That is a wart: they reach into src/ because this crate has no lib
target, so host.rs's `crate::pages` does not resolve from an example's
crate root. A lib.rs would fix it properly and is worth doing if the
example set grows.

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

 examples/wpe_dark.rs  |  44 +++++++++++++++
 examples/wpe_host.rs  |   6 ++
 examples/wpe_input.rs |   6 ++
 examples/wpe_loop.rs  |   6 ++
 examples/wpe_tabs.rs  |   6 ++
 src/wpe/host.rs       | 149 +++++++++++++++++++++++++++++++++++++++++++++++++-
 src/wpe/mod.rs        |   2 +-
 7 files changed, 216 insertions(+), 3 deletions(-)

diff --git a/examples/wpe_dark.rs b/examples/wpe_dark.rs
new file mode 100644
index 0000000..1bb7073
--- /dev/null
+++ b/examples/wpe_dark.rs
@@ -0,0 +1,44 @@
+//! Verifies force-dark actually inverts, by sampling the rendered frame.
+//! `cargo run --release -p cce-browser --features wpe --example wpe_dark`
+
+#[cfg(not(feature = "wpe"))]
+fn main() { eprintln!("build with --features wpe"); }
+
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+    // A deliberately hardcoded-white page: the case force-dark exists for.
+    let url = "data:text/html,<body style='background:%23ffffff'><h1 style='color:%23000'>hello</h1></body>";
+    let mut host = wpe::WebKitHost::new(url::Url::parse(url).unwrap(), (400, 300));
+    let settle = |h: &mut wpe::WebKitHost, n: u32| {
+        for _ in 0..n { h.pump(); std::thread::sleep(std::time::Duration::from_millis(50)); }
+    };
+
+    settle(&mut host, 30);
+    let before = host.sample_pixel();
+    println!("light: top-left pixel = {before:?}");
+
+    println!("-- enabling force-dark --");
+    host.set_force_dark(true);
+    settle(&mut host, 40);
+    let after = host.sample_pixel();
+    println!("dark:  top-left pixel = {after:?}");
+
+    match (before, after) {
+        (Some(b), Some(a)) => {
+            let lum = |p: (u8, u8, u8)| p.0 as u32 + p.1 as u32 + p.2 as u32;
+            println!("\nluminance {} -> {}", lum(b), lum(a));
+            println!("force-dark: {}", if lum(a) < lum(b) / 2 { "OK (page darkened)" } else { "NO EFFECT" });
+        }
+        _ => println!("no frame sampled"),
+    }
+}
diff --git a/examples/wpe_host.rs b/examples/wpe_host.rs
index 1daaa21..c757c4c 100644
--- a/examples/wpe_host.rs
+++ b/examples/wpe_host.rs
@@ -8,6 +8,12 @@ fn main() {
     eprintln!("build with --features wpe");
 }
 
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
 #[cfg(feature = "wpe")]
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
index 3cc83c9..a1f8b93 100644
--- a/examples/wpe_input.rs
+++ b/examples/wpe_input.rs
@@ -11,6 +11,12 @@ fn main() {
     eprintln!("build with --features wpe");
 }
 
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
 #[cfg(feature = "wpe")]
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
index 34f66b4..f0fcf40 100644
--- a/examples/wpe_loop.rs
+++ b/examples/wpe_loop.rs
@@ -15,6 +15,12 @@ fn main() {
     eprintln!("build with --features wpe");
 }
 
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
 #[cfg(feature = "wpe")]
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
index bb27746..cf99535 100644
--- a/examples/wpe_tabs.rs
+++ b/examples/wpe_tabs.rs
@@ -12,6 +12,12 @@ fn main() {
     eprintln!("build with --features wpe");
 }
 
+#[cfg(feature = "wpe")]
+#[path = "../src/pages.rs"]
+mod pages;
+#[cfg(feature = "wpe")]
+#[path = "../src/downloads.rs"]
+mod downloads;
 #[cfg(feature = "wpe")]
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index a629121..26abbb4 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -125,6 +125,18 @@ pub struct WebKitHost {
     pending: Rc<std::cell::RefCell<Pending>>,
     /// GLib's pollfd set, mirrored into one epoll fd for calloop.
     poll: Option<GlibPoll>,
+    /// Shared with the `cce:` pages, exactly as `ServoHost` holds them —
+    /// bookmarks and history are app state, not engine state, so they cross
+    /// the backend swap unchanged.
+    history: std::sync::Arc<crate::pages::History>,
+    bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
+    history_enabled: bool,
+    force_dark: bool,
+    /// Retained only so tests can assert on rendered output; the registry
+    /// owns the copy that actually gets drawn.
+    last_frame: Option<(Vec<u8>, u32, u32)>,
+    /// Installed on every webview when force-dark is on.
+    ucm: *mut WebKitUserContentManager,
 }
 
 unsafe fn cstr(s: &str) -> CString {
@@ -170,6 +182,12 @@ impl WebKitHost {
                 poll: GlibPoll::new()
                     .map_err(|e| log::warn!("no GLib epoll bridge ({e}); pump will poll"))
                     .ok(),
+                history: std::sync::Arc::new(crate::pages::History::load()),
+                bookmarks: std::sync::Arc::new(crate::pages::Bookmarks::load()),
+                history_enabled: true,
+                force_dark: false,
+                last_frame: None,
+                ucm: webkit_user_content_manager_new(),
             };
             host.open_tab(url);
             host
@@ -178,11 +196,13 @@ impl WebKitHost {
 
     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
         unsafe {
-            let prop = cstr("display");
+            let (p_display, p_ucm) = (cstr("display"), cstr("user-content-manager"));
             let wv = g_object_new(
                 webkit_web_view_get_type(),
-                prop.as_ptr(),
+                p_display.as_ptr(),
                 self.display,
+                p_ucm.as_ptr(),
+                self.ucm,
                 std::ptr::null::<c_char>(),
             ) as *mut WebKitWebView;
             let view = webkit_web_view_get_wpe_view(wv);
@@ -314,6 +334,7 @@ impl WebKitHost {
         let Some((px, w, h)) = frame else {
             return (false, dirty);
         };
+        self.last_frame = Some((px.clone(), w, h));
         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)) {
@@ -343,6 +364,13 @@ impl WebKitHost {
         changed
     }
 
+    /// Top-left pixel of the last frame, for tests that need to assert on
+    /// what was actually rendered rather than on what was configured.
+    pub fn sample_pixel(&self) -> Option<(u8, u8, u8)> {
+        let (px, ..) = self.last_frame.as_ref()?;
+        Some((px[0], px[1], px[2]))
+    }
+
     pub fn image(&self) -> Option<(u32, u32, u32)> {
         self.active_tab().image
     }
@@ -378,6 +406,94 @@ impl WebKitHost {
         unsafe { webkit_web_view_can_go_forward(self.active_tab().webview) != 0 }
     }
 
+    // ---- settings and app-side state ----
+    //
+    // These exist so `WebKitHost` and `ServoHost` present the same surface;
+    // bookmarks and history are app state either way, so they are identical.
+
+    pub fn set_history_enabled(&mut self, on: bool) {
+        self.history_enabled = on;
+    }
+
+    /// Install or remove the inverting user stylesheet.
+    ///
+    /// Simpler than the Servo path, which needed *two* timed reloads to let
+    /// a user-content change and a scheme flip settle. WebKit applies user
+    /// content to live pages, so a reload is enough — and only to re-run
+    /// pages that already computed their colours.
+    pub fn set_force_dark(&mut self, on: bool) {
+        if on == self.force_dark {
+            return;
+        }
+        self.force_dark = on;
+        unsafe {
+            if on {
+                let css = cstr(FORCE_DARK_CSS);
+                let sheet = webkit_user_style_sheet_new(
+                    css.as_ptr(),
+                    WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+                    WebKitUserStyleLevel::WEBKIT_USER_STYLE_LEVEL_USER,
+                    std::ptr::null(),
+                    std::ptr::null(),
+                );
+                webkit_user_content_manager_add_style_sheet(self.ucm, sheet);
+                webkit_user_style_sheet_unref(sheet);
+            } else {
+                webkit_user_content_manager_remove_all_style_sheets(self.ucm);
+            }
+            for tab in &self.tabs {
+                webkit_web_view_reload(tab.webview);
+            }
+        }
+    }
+
+    /// What pages see for `prefers-color-scheme`, via WPE's own setting.
+    pub fn set_color_scheme(&self, dark: bool) {
+        unsafe {
+            let settings = wpe_display_get_settings(self.display);
+            let key = cstr("/wpe-platform/dark-mode");
+            let mut err: *mut GError = std::ptr::null_mut();
+            wpe_settings_set_boolean(
+                settings,
+                key.as_ptr(),
+                dark as gboolean,
+                WPESettingsSource::WPE_SETTINGS_SOURCE_APPLICATION,
+                &mut err,
+            );
+        }
+    }
+
+    /// A navigation became a download since the last check. Always false
+    /// until downloads are ported to WebKit's own API.
+    pub fn take_download_started(&self) -> bool {
+        false
+    }
+
+    pub fn active_bookmarked(&self) -> bool {
+        self.active_tab()
+            .url
+            .as_ref()
+            .is_some_and(|u| self.bookmarks.contains(u.as_str()))
+    }
+
+    pub fn toggle_bookmark(&self) {
+        let tab = self.active_tab();
+        if let Some(url) = &tab.url {
+            self.bookmarks
+                .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+        }
+    }
+
+    /// Clipboard on the page. WebKit takes these as named editing commands,
+    /// so unlike the Servo backend there is no separate clipboard delegate to
+    /// implement — it goes through the platform clipboard itself.
+    pub fn editing_action(&self, command: EditingCommand) {
+        unsafe {
+            let c = cstr(command.as_str());
+            webkit_web_view_execute_editing_command(self.active_tab().webview, c.as_ptr());
+        }
+    }
+
     // ---- input ----
     //
     // Coordinates are device pixels relative to the view origin, matching
@@ -558,3 +674,32 @@ unsafe fn from_cstr(p: *const c_char) -> Option<String> {
         .then(|| std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
         .filter(|s| !s.is_empty())
 }
+
+/// Backend-neutral clipboard action, so `main.rs` names neither engine's.
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand {
+    Copy,
+    Cut,
+    Paste,
+}
+
+impl EditingCommand {
+    fn as_str(self) -> &'static str {
+        match self {
+            Self::Copy => "Copy",
+            Self::Cut => "Cut",
+            Self::Paste => "Paste",
+        }
+    }
+}
+
+/// Same inverting stylesheet the Servo backend uses, and for the same reason:
+/// it is the only thing that darkens a page shipping a hardcoded white with no
+/// `prefers-color-scheme` rule to honour.
+const FORCE_DARK_CSS: &str = "\
+html { background-color: #ffffff !important; filter: invert(1) hue-rotate(180deg) !important; }
+img, video, picture, canvas, svg, iframe, embed, object,
+[style*=\"background-image\"], [style*=\"background:url\"] {
+  filter: invert(1) hue-rotate(180deg) !important;
+}
+";
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 3c11e30..052ea04 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -16,4 +16,4 @@ mod host;
 
 // Not consumed yet — main.rs still drives ServoHost.
 #[allow(unused_imports)]
-pub use host::{Tab, WebKitHost};
+pub use host::{EditingCommand, Tab, WebKitHost};