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

commit486c77423ebecf292797e71f201a41b9f16ea16f
parent73b30ed386
authorLucas Galante <[email protected]>
date2026-08-28 11:03
feat(wpe): cce: pages and a persistent profile under WebKit

cce://bookmarks renders correctly on the WebKit backend — same dark page,
same empty state, same URL bar — because both engines now share one
routing table. CceProtocol::route was extracted from the Servo
ProtocolHandler, so the set of pages and their mutating links exists once
and cannot drift between backends; Servo reaches it through
ProtocolHandler, WebKit through its URI-scheme callback.

The two differ in one way worth knowing: Servo's handler runs on fetch
threads, WebKit's on the main thread. The Arc<Mutex<_>> stores are shared
with the Servo path and stay as they are rather than being relaxed, since
both backends are compiled from the same source.

The profile is a WebKitNetworkSession pointed at the same
~/.local/state/cce/browser/profile the Servo backend uses, forced to 0700
for the same reason — the jar holds live sessions. Verified created and
populated: cache, storage and mediakeys all appear, so cookies will
survive a restart rather than every launch starting logged out.

Serving a page hands WebKit an input stream that owns the HTML buffer, so
the box is deliberately leaked into it and freed by the stream's own
destroy notify rather than dropped at the end of the callback.

Downloads are still the honest stub; that is the next piece, and WebKit's
own API should delete the extension sniff entirely — it converts a
navigation to a download by content type, which is exactly the gap the
sniff cannot cover.

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

 build.rs        |   3 ++
 src/pages.rs    |  36 ++++++++++++-------
 src/wpe/host.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 125 insertions(+), 19 deletions(-)

diff --git a/build.rs b/build.rs
index c7f5ba0..7ffab51 100644
--- a/build.rs
+++ b/build.rs
@@ -35,6 +35,9 @@ fn main() {
         .allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
         // The main-context poll protocol: GPollFD is what `query` fills in.
         .allowlist_item("G(MainContext|PollFD|Source).*")
+        // Serving `cce:` pages: the handler answers with an input stream.
+        .allowlist_item("g_(memory_input_stream|input_stream|file)_.*")
+        .allowlist_item("G(InputStream|MemoryInputStream|File|Cancellable|AsyncResult).*")
         .allowlist_item("g_(type|object)_.*")
         // GObject's generated enums are plain C enums; keep them as consts so
         // vfunc tables and property flags stay comparable without casts.
diff --git a/src/pages.rs b/src/pages.rs
index 8c60d83..e560dbb 100644
--- a/src/pages.rs
+++ b/src/pages.rs
@@ -284,17 +284,17 @@ fn cookies_cleared_page() -> String {
     )
 }
 
-impl ProtocolHandler for CceProtocol {
-    fn load(
-        &self,
-        request: &mut Request,
-        _done_chan: &mut DoneChannel,
-        _context: &FetchContext,
-    ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
-        let url = request.current_url();
-        let full = url.as_str().trim_start_matches("cce://");
+impl CceProtocol {
+    /// Route a `cce:` URL to its page. Shared by both engine backends —
+    /// Servo reaches it through `ProtocolHandler` below, WebKit through its
+    /// URI-scheme callback — so the table of pages exists once.
+    ///
+    /// `None` means no such page; the caller turns that into its engine's
+    /// idea of a failed load.
+    pub(crate) fn route(&self, url: &str) -> Option<String> {
+        let full = url.trim_start_matches("cce://");
         let (path, query) = full.split_once('?').unwrap_or((full, ""));
-        let body = match path.trim_end_matches('/') {
+        match path.trim_end_matches('/') {
             "history" => Some(self.history.html()),
             "history/clear" => {
                 self.history.clear();
@@ -320,7 +320,19 @@ impl ProtocolHandler for CceProtocol {
                 Some(cookies_cleared_page())
             }
             _ => None,
-        };
+        }
+    }
+}
+
+impl ProtocolHandler for CceProtocol {
+    fn load(
+        &self,
+        request: &mut Request,
+        _done_chan: &mut DoneChannel,
+        _context: &FetchContext,
+    ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
+        let url = request.current_url();
+        let body = self.route(url.as_str());
         let response = match body {
             Some(html) => {
                 let mut response =
@@ -334,7 +346,7 @@ impl ProtocolHandler for CceProtocol {
                 response
             }
             None => Response::network_error(NetworkError::ResourceLoadError(format!(
-                "no such cce: page: {path}"
+                "no such cce: page: {url}"
             ))),
         };
         Box::pin(std::future::ready(response))
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 1b52bed..c35b9f5 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -15,7 +15,7 @@
 //! 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::ffi::{c_char, c_void, CString};
 use std::rc::Rc;
 
 use url::Url;
@@ -132,6 +132,13 @@ pub struct WebKitHost {
     bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
     history_enabled: bool,
     force_dark: bool,
+    /// Serves the `cce:` pages. Boxed and leaked into the scheme callback,
+    /// so it must outlive every webview.
+    protocol: Rc<crate::pages::CceProtocol>,
+    downloads: std::sync::Arc<crate::downloads::Downloads>,
+    clear_cookies: std::sync::Arc<std::sync::atomic::AtomicBool>,
+    session: *mut WebKitNetworkSession,
+    download_started: Rc<Cell<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)>,
@@ -159,6 +166,49 @@ impl WebKitHost {
                 "wpe_display_connect failed"
             );
 
+            // Persisted profile: without a data directory WebKit keeps cookies
+            // in memory only, so every launch starts logged out of every site.
+            // Same location and the same 0700 reasoning as the Servo backend —
+            // the jar holds live sessions.
+            let profile = crate::pages::state_dir().join("profile");
+            let _ = std::fs::create_dir_all(&profile);
+            {
+                use std::os::unix::fs::PermissionsExt;
+                let _ = std::fs::set_permissions(&profile, std::fs::Permissions::from_mode(0o700));
+            }
+            let (data_dir, cache_dir) = (
+                cstr(&profile.to_string_lossy()),
+                cstr(&profile.join("cache").to_string_lossy()),
+            );
+            let session = webkit_network_session_new(data_dir.as_ptr(), cache_dir.as_ptr());
+
+            let history = std::sync::Arc::new(crate::pages::History::load());
+            let bookmarks = std::sync::Arc::new(crate::pages::Bookmarks::load());
+            let downloads = std::sync::Arc::new(crate::downloads::Downloads::default());
+            let clear_cookies =
+                std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+            let protocol = Rc::new(crate::pages::CceProtocol {
+                history: history.clone(),
+                bookmarks: bookmarks.clone(),
+                downloads: downloads.clone(),
+                clear_cookies: clear_cookies.clone(),
+            });
+
+            // The `cce:` scheme, served straight out of the app exactly as the
+            // Servo backend serves it — same routing table, so the pages and
+            // their mutating links behave identically on both engines.
+            let ctx = webkit_web_context_get_default();
+            let scheme = cstr("cce");
+            webkit_web_context_register_uri_scheme(
+                ctx,
+                scheme.as_ptr(),
+                Some(on_cce_request),
+                Rc::into_raw(protocol.clone()) as gpointer,
+                None,
+            );
+
+            let download_started = Rc::new(Cell::new(false));
+
             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
             let sink = pending.clone();
             FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
@@ -182,10 +232,15 @@ 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: history.clone(),
+                bookmarks: bookmarks.clone(),
                 history_enabled: true,
                 force_dark: false,
+                protocol,
+                downloads,
+                clear_cookies,
+                session,
+                download_started,
                 last_frame: None,
                 ucm: webkit_user_content_manager_new(),
             };
@@ -196,13 +251,19 @@ impl WebKitHost {
 
     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
         unsafe {
-            let (p_display, p_ucm) = (cstr("display"), cstr("user-content-manager"));
+            let (p_display, p_ucm, p_session) = (
+                cstr("display"),
+                cstr("user-content-manager"),
+                cstr("network-session"),
+            );
             let wv = g_object_new(
                 webkit_web_view_get_type(),
                 p_display.as_ptr(),
                 self.display,
                 p_ucm.as_ptr(),
                 self.ucm,
+                p_session.as_ptr(),
+                self.session,
                 std::ptr::null::<c_char>(),
             ) as *mut WebKitWebView;
             let view = webkit_web_view_get_wpe_view(wv);
@@ -473,10 +534,9 @@ impl WebKitHost {
         }
     }
 
-    /// A navigation became a download since the last check. Always false
-    /// until downloads are ported to WebKit's own API.
+    /// A navigation became a download since the last check.
     pub fn take_download_started(&self) -> bool {
-        false
+        self.download_started.replace(false)
     }
 
     pub fn active_bookmarked(&self) -> bool {
@@ -700,3 +760,34 @@ img, video, picture, canvas, svg, iframe, embed, object,
   filter: invert(1) hue-rotate(180deg) !important;
 }
 ";
+
+/// Serves a `cce:` page. Runs on the main thread, unlike the Servo handler
+/// which runs on fetch threads — the `Arc<Mutex<_>>` stores are shared with
+/// that backend and stay as they are.
+unsafe extern "C" fn on_cce_request(request: *mut WebKitURISchemeRequest, data: gpointer) {
+    let protocol = &*(data as *const crate::pages::CceProtocol);
+    let uri = from_cstr(webkit_uri_scheme_request_get_uri(request)).unwrap_or_default();
+    match protocol.route(&uri) {
+        Some(html) => {
+            let len = html.len() as i64;
+            let bytes = html.into_bytes().into_boxed_slice();
+            let ptr = Box::into_raw(bytes) as *mut c_void;
+            // The stream owns the buffer and frees it with g_free, so the box
+            // is deliberately leaked into it rather than dropped here.
+            let stream = g_memory_input_stream_new_from_data(ptr, len, Some(free_boxed));
+            let ctype = cstr("text/html; charset=utf-8");
+            webkit_uri_scheme_request_finish(request, stream, len, ctype.as_ptr());
+            g_object_unref(stream as *mut _);
+        }
+        None => {
+            let msg = cstr(&format!("no such cce: page: {uri}"));
+            let err = g_error_new_literal(1, 0, msg.as_ptr());
+            webkit_uri_scheme_request_finish_error(request, err);
+            g_error_free(err);
+        }
+    }
+}
+
+unsafe extern "C" fn free_boxed(p: gpointer) {
+    drop(Box::from_raw(p as *mut u8));
+}