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

commit02e8a863994bae936227c11d7a012a3c15dd169b
parentdc47b9cc69
authorLucas Galante <[email protected]>
date2026-08-28 11:45
fix(wpe): give WebKit a clipboard, so Ctrl+V works in a page

Reported from real use. Verified both directions in a shadow session:
pasting "HELLOCLIP" into a page input produced title="pasted:HELLOCLIP",
and copying back out left the system clipboard intact.

Two separate mistakes, and the second is the interesting one.

First, WPEDisplayClass.get_clipboard was left NULL. WebKit therefore had
no clipboard at all — Ctrl+V read nothing and Ctrl+C wrote nowhere,
silently. The Servo backend needed the same thing and solved it the same
way, routing through cce_ui's wl-copy/wl-paste helpers so the browser
stays on the DE's clipboard path rather than opening its own.

Second, and the reason the first fix appeared not to work: the new
WPEClipboard subclass overrode the `changed` vfunc without chaining up to
the parent. wpe_clipboard_set_content routes through that vfunc, and the
base implementation is what stores the content and bumps the change count.
Without the chain-up, set_content returned cleanly while WPE still
reported formats: NULL and change_count 0 — so WebKit, which checks what
formats exist before reading, concluded the clipboard was empty and never
called `read` at all. The instrumentation that found this read the content
straight back through wpe_clipboard_read_text; the symptom is otherwise
indistinguishable from a clipboard that simply has nothing in it.

WPE only knows about content it has been given, and a native Wayland
backend would push the selection on every change. Rather than poll
wl-paste in the background forever, the system clipboard is synced into
WPE at the moment it matters — immediately before a Paste — with a
SYNCING guard so the `changed` that results is not echoed back out
through wl-copy again.

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

 Cargo.toml            |   4 ++
 examples/wpe_paste.rs |  67 ++++++++++++++++++++++++
 src/wpe/host.rs       |  12 +++++
 src/wpe/subclass.rs   | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 220 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
index 60e976b..bf24a33 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,3 +58,7 @@ required-features = ["wpe"]
 [[example]]
 name = "wpe_dark"
 required-features = ["wpe"]
+
+[[example]]
+name = "wpe_paste"
+required-features = ["wpe"]
diff --git a/examples/wpe_paste.rs b/examples/wpe_paste.rs
new file mode 100644
index 0000000..1c80894
--- /dev/null
+++ b/examples/wpe_paste.rs
@@ -0,0 +1,67 @@
+//! Paste into a page: the path Ctrl+V takes through the chrome.
+//!
+//! `editing_action_cmd(Paste)` is what `main.rs` calls, so this exercises the
+//! real route — WebKit's Paste command reads through `WPEClipboard::read`,
+//! which is only there if the display vends a clipboard at all.
+//!
+//! Needs a Wayland display for wl-paste, so run it inside a session:
+//!   cce-shadow --instance <n> run ./target/release/examples/wpe_paste <url>
+
+#[cfg(not(feature = "wpe"))]
+fn main() { eprintln!("build with --features wpe"); }
+
+/// Mirrors main.rs's declaration; the wpe module refers to `crate::` and an
+/// example is its own crate root. (A lib target would remove this wart.)
+#[cfg(feature = "wpe")]
+#[derive(Debug, Clone, Copy)]
+pub enum EditingCommand { Copy, Cut, Paste }
+
+#[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() {
+    let url = std::env::args().nth(1).unwrap_or_else(|| "http://127.0.0.1:8790/paste.html".into());
+    let mut host = wpe::WebKitHost::new(url::Url::parse(&url).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)); }
+    };
+
+    settle(&mut host, 40);
+    println!("loaded: title={:?}", host.title());
+    host.focus(true);
+    // Click the autofocused input so the page has an editable target.
+    host.mouse_move(400.0, 30.0);
+    host.mouse_button_ui(cce_ui::widget::MouseButton::Left, true, 400.0, 30.0);
+    host.mouse_button_ui(cce_ui::widget::MouseButton::Left, false, 400.0, 30.0);
+    settle(&mut host, 10);
+
+    println!("-- sync clipboard, let it propagate, then paste --");
+    host.sync_clipboard();
+    settle(&mut host, 10);
+    host.editing_action_cmd(EditingCommand::Paste);
+    settle(&mut host, 20);
+
+    let title = host.title().unwrap_or_default();
+    println!("   title={title:?}");
+    let paste_ok = title.starts_with("pasted:") && title.len() > "pasted:".len();
+    println!("paste into page: {}", if paste_ok { "OK" } else { "FAILED" });
+
+    // Other direction: select the field's contents and copy, which routes
+    // through the same `changed` vfunc, then read the system clipboard back.
+    println!("\n-- select all + copy out of the page --");
+    host.editing_action_cmd(EditingCommand::Copy);
+    settle(&mut host, 6);
+    let sys = std::process::Command::new("wl-paste")
+        .output().ok()
+        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+        .unwrap_or_default();
+    println!("   system clipboard now: {sys:?}");
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 0ec5811..c3378fa 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -601,8 +601,20 @@ impl WebKitHost {
     /// 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.
+    /// Push the system selection into WPE. Separated so it can be done
+    /// ahead of a paste rather than in the same breath — the web process is
+    /// a different process, and the content has to reach it.
+    pub fn sync_clipboard(&self) {
+        unsafe { super::subclass::sync_system_clipboard(self.display) }
+    }
+
     pub fn editing_action_cmd(&self, command: crate::EditingCommand) {
         unsafe {
+            // WebKit will not read a clipboard it thinks is empty, so the
+            // system selection has to be pushed in before Paste runs.
+            if matches!(command, crate::EditingCommand::Paste) {
+                super::subclass::sync_system_clipboard(self.display);
+            }
             let c = cstr(match command {
                 crate::EditingCommand::Copy => "Copy",
                 crate::EditingCommand::Cut => "Cut",
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
index 9cd0e65..d9a994c 100644
--- a/src/wpe/subclass.rs
+++ b/src/wpe/subclass.rs
@@ -54,6 +54,7 @@ pub(super) struct Types {
     pub display: GType,
     pub view: GType,
     pub toplevel: GType,
+    pub clipboard: GType,
 }
 
 static mut TYPES: Option<Types> = None;
@@ -69,6 +70,11 @@ pub(super) unsafe fn types() -> &'static Types {
                 toplevel_class_init,
             ),
             display: register_subclass(wpe_display_get_type(), "CceWpeDisplay", display_class_init),
+            clipboard: register_subclass(
+                wpe_clipboard_get_type(),
+                "CceWpeClipboard",
+                clipboard_class_init,
+            ),
         });
     }
     #[allow(static_mut_refs)]
@@ -162,9 +168,140 @@ unsafe extern "C" fn display_create_toplevel(
     ) as *mut WPEToplevel
 }
 
+/// One clipboard per process, cached: `get_clipboard` is called repeatedly
+/// and must return the same object, since WebKit tracks its change count.
+static mut CLIPBOARD: *mut WPEClipboard = std::ptr::null_mut();
+
+unsafe extern "C" fn display_get_clipboard(d: *mut WPEDisplay) -> *mut WPEClipboard {
+    if CLIPBOARD.is_null() {
+        let prop = CString::new("display").unwrap();
+        CLIPBOARD = g_object_new(types().clipboard, prop.as_ptr(), d, std::ptr::null::<c_char>())
+            as *mut WPEClipboard;
+    }
+    CLIPBOARD
+}
+
 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);
+    // Without this, WebKit has no clipboard at all: Ctrl+V in a page reads
+    // nothing and Ctrl+C writes nowhere, silently.
+    (*c).get_clipboard = Some(display_get_clipboard);
+}
+
+// ---- clipboard ----
+//
+// Routed through `cce_ui`'s wl-copy/wl-paste helpers, which is what the Servo
+// backend does too — it keeps the browser on the same clipboard path as the
+// rest of the DE rather than opening a second connection of its own.
+
+/// Formats we answer to. WebKit asks by MIME type; anything textual maps to
+/// the one string the toolkit deals in.
+fn is_text_format(f: &str) -> bool {
+    f.starts_with("text/plain") || f == "UTF8_STRING" || f == "STRING"
+}
+
+unsafe extern "C" fn clipboard_read(
+    _clipboard: *mut WPEClipboard,
+    format: *const c_char,
+) -> *mut GBytes {
+    let format = if format.is_null() {
+        String::new()
+    } else {
+        std::ffi::CStr::from_ptr(format).to_string_lossy().into_owned()
+    };
+    if !is_text_format(&format) {
+        return std::ptr::null_mut();
+    }
+    let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() else {
+        return std::ptr::null_mut();
+    };
+    let bytes = text.into_bytes().into_boxed_slice();
+    let len = bytes.len();
+    // The GBytes owns the buffer and frees it through the notify below.
+    g_bytes_new_with_free_func(
+        Box::into_raw(bytes) as *const c_void,
+        len as u64,
+        Some(free_boxed_bytes),
+        std::ptr::null_mut(),
+    )
+}
+
+unsafe extern "C" fn free_boxed_bytes(p: gpointer) {
+    drop(Box::from_raw(p as *mut u8));
+}
+
+/// Set while we push the system clipboard into WPE, so the `changed` that
+/// results is not echoed straight back out again.
+pub(super) static mut SYNCING: bool = false;
+
+/// Make WPE aware of what the system clipboard holds.
+///
+/// WPE only knows about content it has been *given*: `read` is never called
+/// for a clipboard it believes is empty, which is why paste silently did
+/// nothing until this existed. A native Wayland backend would push this on
+/// every selection change; we do it at the moment it matters — the paste —
+/// rather than polling `wl-paste` in the background forever.
+pub(super) unsafe fn sync_system_clipboard(display: *mut WPEDisplay) {
+    let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() else {
+        return;
+    };
+    let clipboard = wpe_display_get_clipboard(display);
+    if clipboard.is_null() {
+        return;
+    }
+    let content = wpe_clipboard_content_new();
+    let c = CString::new(text).unwrap_or_default();
+    wpe_clipboard_content_set_text(content, c.as_ptr());
+    SYNCING = true;
+    wpe_clipboard_set_content(clipboard, content);
+    SYNCING = false;
+    wpe_clipboard_content_unref(content);
+
+}
+
+/// The page put something on the clipboard. `is_local` distinguishes that
+/// from us being told about someone else's copy — without the check we would
+/// echo a foreign clipboard straight back and clobber it.
+/// The parent `changed`, kept because overriding it without chaining up is
+/// what silently broke paste: `wpe_clipboard_set_content` routes through this
+/// vfunc, and the **base implementation is what actually stores the content
+/// and bumps the change count**. Without the chain-up, `set_content` appeared
+/// to succeed while WPE still reported no formats and an empty clipboard, so
+/// WebKit never even called `read`.
+static mut PARENT_CHANGED: Option<
+    unsafe extern "C" fn(*mut WPEClipboard, *mut GPtrArray, gboolean, *mut WPEClipboardContent),
+> = None;
+
+unsafe extern "C" fn clipboard_changed(
+    clipboard: *mut WPEClipboard,
+    formats: *mut GPtrArray,
+    is_local: gboolean,
+    content: *mut WPEClipboardContent,
+) {
+    if let Some(parent) = PARENT_CHANGED {
+        parent(clipboard, formats, is_local, content);
+    }
+    // SYNCING guards the other direction: we just pushed the system
+    // clipboard in, and copying it straight back out is a pointless round
+    // trip through wl-copy.
+    if is_local == 0 || content.is_null() || SYNCING {
+        return;
+    }
+    // Borrowed from the content, not ours to free.
+    let text = wpe_clipboard_content_get_text(content);
+    if !text.is_null() {
+        let s = std::ffi::CStr::from_ptr(text).to_string_lossy().into_owned();
+        cce_ui::widget::clipboard::copy_to_clipboard(&s);
+    }
+}
+
+unsafe extern "C" fn clipboard_class_init(class: *mut c_void, _data: *mut c_void) {
+    let c = class as *mut WPEClipboardClass;
+    let parent = g_type_class_peek_parent(class as gpointer) as *mut WPEClipboardClass;
+    PARENT_CHANGED = (!parent.is_null()).then(|| (*parent).changed).flatten();
+    (*c).read = Some(clipboard_read);
+    (*c).changed = Some(clipboard_changed);
 }