web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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);
}